From f4d737bce1215e6e0c93b6e5159a4e4757cd3115 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Thu, 16 Apr 2026 14:04:58 +0500 Subject: [PATCH 1/9] Fix: update tarpaulin notes and setup. --- server/.cargo/config.toml | 28 ++-------------------------- server/.gitignore | 8 +++----- 2 files changed, 5 insertions(+), 31 deletions(-) diff --git a/server/.cargo/config.toml b/server/.cargo/config.toml index 62c59ce9..7f3b9ce6 100644 --- a/server/.cargo/config.toml +++ b/server/.cargo/config.toml @@ -17,31 +17,7 @@ TS_RS_EXPORT_DIR = { value = "../client/src/rust-types", relative = true } # TypeScript's convention. TS_RS_IMPORT_EXTENSION = "ts" -# Code coverage, the manual way: -# -# 1. Run `cargo install rustfilt` per the -# [code coverage docs](https://doc.rust-lang.org/rustc/instrument-coverage.html#building-the-demangler). -# 2. You must manually run `rustup component add llvm-tools-preview` following -# the -# [coverge docs](https://doc.rust-lang.org/rustc/instrument-coverage.html#installing-llvm-coverage-tools). -# Per some searching, also run `cargo install cargo-binutils` to put these -# tools in the path. -# 3. In Powershell, `$Env:RUSTFLAGS = "-C instrument-coverage"` then `cargo -# test`. When the tests run, record the name of the test binary. -# 4. `rust-profdata merge -sparse default_*.profraw -o default.profdata`. -# 5. `rust-cov show --Xdemangler=rustfilt -# target\debug\deps\code_chat_editor-4dbe5c7815a53cd9.exe -# --instr-profile=default.profdata --ignore-filename-regex=\\.cargo\\registry -# --format=html --output-dir=coverage`, replacing the binary path with the -# one recorded in step 3. -# 6. Open the file `coverage\index.html`. -# -# Or, `cargo install cargo-tarpaulin` then `cargo tarpaulin --ignore-panics -# --out=html --skip-clean`. [build] -# Set these to match the output from `cargo tarpaulin --print-rust-flags` to -# avoid recompiles. -# -# This is commented out; for development, uncomment this. -##rustflags = ["-Cdebuginfo=2", "-Cstrip=none", "--cfg=tarpaulin", "-Cinstrument-coverage"] +# To check coverage, `cargo install cargo-tarpaulin` then `cargo tarpaulin +# --skip-clean --out=html --target-dir=tarpaulin`. \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore index 52b06b87..c935c389 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -23,14 +23,12 @@ target/ # Output from profiling. +tarpaulin/ *.profraw tarpaulin-report.html -# Copied files needed to `cargo publish`. -static/ +# Other files. hashLocations.json - -config.json *.log -.windows/ + # CodeChat Editor lexer: python. See TODO. From 8698a0a514bed37f91c8d789b7312c9fd76a9e4a Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 17 Apr 2026 11:04:40 +0500 Subject: [PATCH 2/9] Fix: improve Selenium test robustness. --- server/tests/overall_2.rs | 20 +++++++++++++++++++- server/tests/overall_common/mod.rs | 1 + 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/server/tests/overall_2.rs b/server/tests/overall_2.rs index 70a3d528..54a07d7d 100644 --- a/server/tests/overall_2.rs +++ b/server/tests/overall_2.rs @@ -374,7 +374,7 @@ async fn test_6_core( // Perform edits. body_content.send_keys("a").await.unwrap(); - let client_id = INITIAL_CLIENT_MESSAGE_ID; + let mut client_id = INITIAL_CLIENT_MESSAGE_ID; let msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); let client_version = get_version(&msg); assert_eq!( @@ -406,6 +406,24 @@ async fn test_6_core( ); let version = client_version; codechat_server.send_result(client_id, None).await.unwrap(); + client_id += MESSAGE_ID_INCREMENT; + + // Wait for a second update that's empty. Not sure why. + 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: None, + includes_marker: false, + scroll_position: None, + is_re_translation: false, + contents: None, + }) + } + ); + codechat_server.send_result(client_id, None).await.unwrap(); //client_id += MESSAGE_ID_INCREMENT; // Send new text, which turns into a diff. diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index 894a4e40..287a03d6 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -310,6 +310,7 @@ pub async fn goto_line( && update.contents.is_none() && update.cursor_position != Some(line) { + codechat_server.send_result(*client_id, None).await.unwrap(); *client_id += MESSAGE_ID_INCREMENT; msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); } From ed706f337801fb93e3bacf833af67de7e3a0faea Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 21 Apr 2026 16:37:12 +0500 Subject: [PATCH 3/9] Fix: correctly append final text from Markdown conversion to last doc block. Misc DRY. --- server/src/processing.rs | 44 ++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/server/src/processing.rs b/server/src/processing.rs index dcb2ecc9..ebbf12b7 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -420,15 +420,14 @@ pub fn codechat_for_web_to_source( ) -> Result { let lexer_name = &codechat_for_web.metadata.mode; // Given the mode, find the lexer. - let lexer: &std::sync::Arc = - match LEXERS.map_mode_to_lexer.get(lexer_name) { - Some(v) => v, - None => { - return Err(CodechatForWebToSourceError::InvalidLexer( - lexer_name.clone(), - )); - } - }; + let lexer = match LEXERS.map_mode_to_lexer.get(lexer_name) { + Some(v) => v, + None => { + return Err(CodechatForWebToSourceError::InvalidLexer( + lexer_name.clone(), + )); + } + }; // Extract the plain (not diffed) CodeMirror contents. let CodeMirrorDiffable::Plain(ref code_mirror) = codechat_for_web.source else { @@ -577,7 +576,7 @@ impl HtmlToMarkdownWrapped { // ignored (by an // [ignoreFileDirective](https://dprint.dev/plugins/markdown/config/)). // Simply return the unchanged text in this case. - .unwrap_or_else(|| converted.to_string()), + .unwrap_or(converted), ) } @@ -585,11 +584,7 @@ impl HtmlToMarkdownWrapped { let converted = self.html_to_markdown.finalize_conversion(); Ok( format_text(&converted, &self.word_wrap_config, |_, _, _| Ok(None))? - // A return value of `None` means the text was unchanged or - // ignored (by an - // [ignoreFileDirective](https://dprint.dev/plugins/markdown/config/)). - // Simply return the unchanged text in this case. - .unwrap_or_else(|| converted.to_string()), + .unwrap_or(converted), ) } @@ -606,9 +601,11 @@ fn doc_block_html_to_markdown( mut code_doc_block_vec: Vec, ) -> Result, HtmlToMarkdownWrappedError> { let mut converter = HtmlToMarkdownWrapped::new(); - for code_doc_block in &mut code_doc_block_vec { + let mut last_doc_block_index = None; + for (index, code_doc_block) in &mut code_doc_block_vec.iter_mut().enumerate() { if let CodeDocBlock::DocBlock(doc_block) = code_doc_block { - let tree = html_to_tree(&doc_block.contents)?; + last_doc_block_index = Some(index); + let tree = html_to_tree(&doc_block.contents, dom_offsets)?; dehydrating_walk_node(&tree); // Compute a line wrap width based on the current indent. Set a @@ -629,12 +626,15 @@ fn doc_block_html_to_markdown( } } - // Output the finalized conversion. - if let Some(code_doc_block) = code_doc_block_vec.last_mut() - && let CodeDocBlock::DocBlock(doc_block) = code_doc_block - { + // Append the finalized conversion to the last doc block. + if let Some(last_doc_block_index) = last_doc_block_index { + let CodeDocBlock::DocBlock(ref mut last_doc_block) = + code_doc_block_vec[last_doc_block_index] + else { + unreachable!(); + }; let last = converter.last()?; - doc_block.contents.push_str(&last); + last_doc_block.contents.push_str(&last); } Ok(code_doc_block_vec) From 667b26bcccc74767939afd253930cf134095a533 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 21 Apr 2026 16:35:41 +0500 Subject: [PATCH 4/9] wip: Locate cursor line within Client doc blocks. --- client/src/CodeChatEditor.mts | 20 ++++++++------ client/src/CodeChatEditorFramework.mts | 5 ++-- client/src/CodeMirror-integration.mts | 32 ++++++++++++++-------- extensions/VSCode/src/extension.ts | 24 +++++++++------- server/src/ide.rs | 8 +++--- server/src/processing.rs | 38 ++++++++++++++++++-------- server/src/processing/tests.rs | 2 +- server/src/translation.rs | 22 +++++++++++++++ server/src/webserver.rs | 24 +++++++++++++++- server/tests/overall_1.rs | 22 +++++++-------- server/tests/overall_2.rs | 17 ++++++------ server/tests/overall_common/mod.rs | 8 +++--- 12 files changed, 150 insertions(+), 72 deletions(-) diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index 59411f89..6e4947df 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -76,6 +76,7 @@ import { show_toast } from "./show_toast.mjs"; // ### CSS import "./css/CodeChatEditor.css"; +import { CursorPosition } from "./rust-types/CursorPosition.js"; // Data structures // --------------- @@ -103,12 +104,12 @@ declare global { open_lp: ( codechat_for_web: CodeChatForWeb, is_re_translation: boolean, - cursor_line?: number, + cursor_position?: CursorPosition, scroll_line?: number, ) => Promise; on_save: (_only_if_dirty: boolean) => Promise; scroll_to_line: ( - cursor_line?: number, + cursor_position?: CursorPosition, scroll_line?: number, ) => void; show_toast: (text: string) => void; @@ -172,7 +173,7 @@ const is_doc_only = () => { const open_lp = async ( codechat_for_web: CodeChatForWeb, is_re_translation: boolean, - cursor_line?: number, + cursor_line?: CursorPosition, scroll_line?: number, ) => // Wait for the DOM to load before opening the file. @@ -205,7 +206,7 @@ const _open_lp = async ( // associated metadata. See [`AllSource`](#AllSource). codechat_for_web: CodeChatForWeb, is_re_translation: boolean, - cursor_line?: number, + cursor_position?: CursorPosition, scroll_line?: number, ) => { // Note that globals, such as `is_dirty` and document contents, may change @@ -331,7 +332,7 @@ const _open_lp = async ( } } await mathJaxTypeset(codechat_body); - scroll_to_line(cursor_line, scroll_line); + scroll_to_line(cursor_position, scroll_line); } else { if (is_dirty && "Diff" in source) { // Send an `OutOfSync` response, so that the IDE will send the @@ -349,7 +350,7 @@ const _open_lp = async ( codechat_body, codechat_for_web, [], - cursor_line, + cursor_position, scroll_line, ); } @@ -656,11 +657,14 @@ const save_then_navigate = (codeChatEditorUrl: URL) => { // This can be called by the framework. Therefore, make no assumptions about // variables being valid; it be called before a file is loaded, etc. -const scroll_to_line = (cursor_line?: number, scroll_line?: number) => { +const scroll_to_line = ( + cursor_position?: CursorPosition, + scroll_line?: number, +) => { if (is_doc_only()) { // TODO. } else { - codemirror_scroll_to_line(cursor_line, scroll_line); + codemirror_scroll_to_line(cursor_position, scroll_line); } }; diff --git a/client/src/CodeChatEditorFramework.mts b/client/src/CodeChatEditorFramework.mts index f871a210..692617b1 100644 --- a/client/src/CodeChatEditorFramework.mts +++ b/client/src/CodeChatEditorFramework.mts @@ -47,6 +47,7 @@ import { on_dom_content_loaded, } from "./CodeChatEditor.mjs"; import { ResultErrTypes } from "./rust-types/ResultErrTypes.js"; +import { CursorPosition } from "./rust-types/CursorPosition.js"; // Websocket // --------- @@ -427,7 +428,7 @@ const get_client = () => root_iframe?.contentWindow?.CodeChatEditor; const set_content = async ( contents: CodeChatForWeb, is_re_translation: boolean, - cursor_line?: number, + cursor_position?: CursorPosition, scroll_line?: number, ) => { const client = get_client(); @@ -448,7 +449,7 @@ const set_content = async ( await root_iframe!.contentWindow!.CodeChatEditor.open_lp( contents, is_re_translation, - cursor_line, + cursor_position, scroll_line, ); } diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts index fa076ab6..4a575bae 100644 --- a/client/src/CodeMirror-integration.mts +++ b/client/src/CodeMirror-integration.mts @@ -108,6 +108,7 @@ import { } from "./shared.mjs"; import { assert } from "./assert.mjs"; import { show_toast } from "./show_toast.mjs"; +import { CursorPosition } from "./rust-types/CursorPosition"; // Globals // ------- @@ -922,7 +923,7 @@ export const CodeMirror_load = async ( codechat_for_web: CodeChatForWeb, // Additional extensions. extensions: Array, - cursor_line?: number, + cursor_position?: CursorPosition, scroll_line?: number, ) => { if ("Plain" in codechat_for_web.source) { @@ -1135,12 +1136,15 @@ export const CodeMirror_load = async ( annotations: noAutosaveAnnotation.of(true), }); } - scroll_to_line(cursor_line, scroll_line); + scroll_to_line(cursor_position, scroll_line); }; // Scroll to the provided `scroll_line`; place the cursor at `cursor_line`. -export const scroll_to_line = (cursor_line?: number, scroll_line?: number) => { - if (cursor_line === undefined && scroll_line === undefined) { +export const scroll_to_line = ( + cursor_position?: CursorPosition, + scroll_line?: number, +) => { + if (cursor_position === undefined && scroll_line === undefined) { return; } @@ -1150,13 +1154,19 @@ export const scroll_to_line = (cursor_line?: number, scroll_line?: number) => { const dispatch_data: TransactionSpec = { annotations: noAutosaveAnnotation.of(true), }; - if (cursor_line !== undefined) { + if (cursor_position !== undefined) { // Translate the line numbers to a position. - const cursor_pos = current_view?.state.doc.line(cursor_line).from; - dispatch_data.selection = { - anchor: cursor_pos, - head: cursor_pos, - }; + if ("Line" in cursor_position) { + const cursor_pos = current_view?.state.doc.line( + cursor_position.Line, + ).from; + dispatch_data.selection = { + anchor: cursor_pos, + head: cursor_pos, + }; + } else { + report_error("Not supported."); + } // If a scroll position is provided, use it; otherwise, scroll the // cursor into the current view. if (scroll_line == undefined) { @@ -1222,7 +1232,7 @@ export const set_CodeMirror_positions = ( current_view.state.selection.main.from, ).number; } - update_message_contents.cursor_position = cursor_line; + update_message_contents.cursor_position = { Line: cursor_line }; // `current_view.viewport.from` isn't accurate, since it's not really the // top line, but a margin before it; see the diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index fe592075..843eeb35 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -437,19 +437,23 @@ export const activate = (context: vscode.ExtensionContext) => { ); } - const cursor_line = current_update.cursor_position; - if (cursor_line !== undefined && editor) { + const cursor_position = + current_update.cursor_position; + if (cursor_position !== undefined && editor) { + assert("Line" in cursor_position); + const cursor_line = cursor_position.Line; ignore_selection_change = true; - const cursor_position = new vscode.Position( - // The VSCode line is zero-based; the - // CodeMirror line is one-based. - cursor_line - 1, - 0, - ); + const vscode_cursor_position = + new vscode.Position( + // The VSCode line is zero-based; the + // CodeMirror line is one-based. + cursor_line - 1, + 0, + ); editor.selections = [ new vscode.Selection( - cursor_position, - cursor_position, + vscode_cursor_position, + vscode_cursor_position, ), ]; // I'd prefer to set `ignore_selection_change = diff --git a/server/src/ide.rs b/server/src/ide.rs index b609b441..67d139de 100644 --- a/server/src/ide.rs +++ b/server/src/ide.rs @@ -66,9 +66,9 @@ use crate::{ processing::{CodeChatForWeb, CodeMirror, CodeMirrorDiffable, SourceFileMetadata}, translation::{CreatedTranslationQueues, create_translation_queues}, webserver::{ - self, EditorMessage, EditorMessageContents, INITIAL_IDE_MESSAGE_ID, MESSAGE_ID_INCREMENT, - REPLY_TIMEOUT_MS, ResultErrTypes, ResultOkTypes, UpdateMessageContents, WebAppState, - setup_server, + self, CursorPosition, EditorMessage, EditorMessageContents, INITIAL_IDE_MESSAGE_ID, + MESSAGE_ID_INCREMENT, REPLY_TIMEOUT_MS, ResultErrTypes, ResultOkTypes, + UpdateMessageContents, WebAppState, setup_server, }, }; @@ -271,7 +271,7 @@ impl CodeChatEditorServer { ) -> std::io::Result { self.send_message_timeout(EditorMessageContents::Update(UpdateMessageContents { file_path, - cursor_position, + cursor_position: cursor_position.map(CursorPosition::Line), scroll_position: scroll_position.map(|x| x as f32), is_re_translation: false, contents: option_contents.map(|contents| CodeChatForWeb { diff --git a/server/src/processing.rs b/server/src/processing.rs index ebbf12b7..07f9dbf7 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -442,14 +442,14 @@ pub fn codechat_for_web_to_source( } // Translate the HTML document to Markdown. let converter = HtmlToMarkdownWrapped::new(); - let tree = html_to_tree(&code_mirror.doc)?; + let tree = html_to_tree(&code_mirror.doc, &None)?; dehydrating_walk_node(&tree); return converter .convert(&tree) .map_err(CodechatForWebToSourceError::HtmlToMarkdownFailed); } let code_doc_block_vec_html = code_mirror_to_code_doc_blocks(code_mirror); - let code_doc_block_vec = doc_block_html_to_markdown(code_doc_block_vec_html) + let code_doc_block_vec = doc_block_html_to_markdown(code_doc_block_vec_html, &None) .map_err(CodechatForWebToSourceError::HtmlToMarkdownFailed)?; code_doc_block_vec_to_source(&code_doc_block_vec, lexer) .map_err(CodechatForWebToSourceError::CannotTranslateCodeChat) @@ -599,6 +599,10 @@ impl HtmlToMarkdownWrapped { // Transform HTML in doc blocks to Markdown. fn doc_block_html_to_markdown( mut code_doc_block_vec: Vec, + // If provided, the index of each successive node in the DOM, ending with + // the offset within the last node (which must be a text node), at which a + // marker character will be inserted. + dom_offsets: &Option>, ) -> Result, HtmlToMarkdownWrappedError> { let mut converter = HtmlToMarkdownWrapped::new(); let mut last_doc_block_index = None; @@ -1033,8 +1037,17 @@ fn markdown_to_html(markdown: &str) -> String { html_output } -// Use html5ever to parse a string containing HTML to a DOM tree. -fn html_to_tree(html: &str) -> io::Result> { +// Mark the current cursor position with a +// [private use area code point](https://en.wikipedia.org/wiki/Private_Use_Areas), +// something that's unlike to appear in source code. +pub const UNICODE_CURSOR_MARKER: char = '\u{E83B}'; + +/// Use html5ever to parse a string containing HTML to a DOM tree. +fn html_to_tree( + html: &str, + // See the same parameter from `doc_block_html_to_markdown`. + dom_offsets: &Option>, +) -> io::Result> { let dom = parse_document( RcDom::default(), ParseOpts { @@ -1048,12 +1061,15 @@ fn html_to_tree(html: &str) -> io::Result> { .from_utf8() .read_from(&mut html.as_bytes())?; - // TODO: should we report parse errors? If so, how and where? - /*** - if let Some(err) = dom.errors.borrow().first() { - //return Err(io::Error::other(err.to_string())); - } - */ + if let Some(dom_offsets) = dom_offsets { + // TODO: each element in `dom_offsets` is the index of a node in the + // `dom`. Take the first index, then descend into the indicated node. + // Repeat this process until the last node, which should be a text node. + // The last index is the offset with the text contents to insert a + // `UNICODE_CURSOR_MARKER` character. Any failures (index exceeds number + // of nodes, etc.) should use an approximation where possible (use the + // last node). + } Ok(dom.document) } @@ -1061,7 +1077,7 @@ fn html_to_tree(html: &str) -> io::Result> { // A framework to transform HTML by parsing it to a DOM tree, walking the tree, // then serializing the tree back to an HTML string. pub fn transform_html)>(html: &str, transform: T) -> io::Result { - let tree = html_to_tree(html)?; + let tree = html_to_tree(html, &None)?; transform(tree.clone()); // Serialize the transformed DOM back to a string. diff --git a/server/src/processing/tests.rs b/server/src/processing/tests.rs index d54f3848..4369c82c 100644 --- a/server/src/processing/tests.rs +++ b/server/src/processing/tests.rs @@ -1326,7 +1326,7 @@ fn test_hydrate_html_1() { } fn dehydrate_html(html: &str) -> io::Result> { - let tree = html_to_tree(html)?; + let tree = html_to_tree(html, &None)?; dehydrating_walk_node(&tree); //println!("{:#?}", tree); Ok(tree) diff --git a/server/src/translation.rs b/server/src/translation.rs index 5bbf5908..a60c7398 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -1181,6 +1181,28 @@ impl TranslationTask { } }, }; + + // TODO: if the Client's `Update` message contains a + // `cursor_position.DomLocation`, translate it to a `Line` using + // the following process: + // + // 1. If this is a Markdown document, there are zero preceding + // newlines and the relevant HTML is in + // `self.code_mirror_doc`. Otherwise: + // 1. Locate the relevant doc block, identified by + // `self.code_mirror_doc_blocks.from == + // cursor_position.DomLocation.from`. + // 2. Count all newlines in `self.code_mirror_doc` which + // precede `cursor_position.DomLocation.from` + // 2. Create a temporary one-element `Vec`, + // containing only the doc block / HTML just identified. + // 3. Invoke `processing::doc_block_html_to_markdown` on this + // vec, passing `cursor_position.DomLocation.dom_offsets` to + // insert a marker character. + // 4. Count the number of newlines before the marker. Add this + // to the number of preceding newlines for a final + // `cursor_position.Line` value. + debug!("Sending update id = {}", client_message.id); queue_send_func!(self.to_ide_tx.send(EditorMessage { id: client_message.id, diff --git a/server/src/webserver.rs b/server/src/webserver.rs index b4fb389e..6dc146f1 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -346,7 +346,7 @@ pub struct UpdateMessageContents { /// The line in the file where the cursor is located. TODO: Selections are /// not yet supported. #[serde(skip_serializing_if = "Option::is_none")] - pub cursor_position: Option, + pub cursor_position: Option, /// The line at the top of the screen. #[serde(skip_serializing_if = "Option::is_none")] pub scroll_position: Option, @@ -360,6 +360,28 @@ pub struct UpdateMessageContents { pub contents: Option, } +/// Store the location of the cursor (the selection, assuming it's a zero-length +/// selection, i.e. a standard cursor). +#[derive(Debug, Serialize, Deserialize, PartialEq, TS)] +#[ts(export)] +pub enum CursorPosition { + /// The line the cursor is on. + Line(u32), + /// The exact location of the cursor in the HTML DOM. Only the Client and + /// the Server may use this in messages to each other. The IDE will not + /// receive a message with this variant and must not generate a message with + /// this variant. + DomLocation { + // The `from` location (character offset) of the doc block the cursor is + // in. + from: usize, + // The index of each successive node in the DOM, ending with the offset + // within the last node (which must be a text node), of the current + // selection (cursor location). + dom_offsets: Vec, + }, +} + /// ### Data structures used by the webserver /// /// Define the [state](https://actix.rs/docs/application/#state) available to diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index 317931d6..88eb65cc 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -59,8 +59,8 @@ use code_chat_editor::{ CodeChatForWeb, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata, StringDiff, }, webserver::{ - EditorMessage, EditorMessageContents, INITIAL_CLIENT_MESSAGE_ID, MESSAGE_ID_INCREMENT, - ResultOkTypes, UpdateMessageContents, set_root_path, + CursorPosition, EditorMessage, EditorMessageContents, INITIAL_CLIENT_MESSAGE_ID, + MESSAGE_ID_INCREMENT, ResultOkTypes, UpdateMessageContents, set_root_path, }, }; use test_utils::{cast, prep_test_dir}; @@ -123,7 +123,7 @@ async fn test_server_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: None, @@ -150,7 +150,7 @@ async fn test_server_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: Some(CodeChatForWeb { @@ -186,7 +186,7 @@ async fn test_server_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: Some(CodeChatForWeb { @@ -226,7 +226,7 @@ async fn test_server_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(2), + cursor_position: Some(CursorPosition::Line(2)), scroll_position: Some(1.0), is_re_translation: false, contents: None, @@ -247,7 +247,7 @@ async fn test_server_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: None, @@ -269,7 +269,7 @@ async fn test_server_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(2), + cursor_position: Some(CursorPosition::Line(2)), scroll_position: Some(1.0), is_re_translation: false, contents: Some(CodeChatForWeb { @@ -776,7 +776,7 @@ async fn test_client_updates_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: None, @@ -795,7 +795,7 @@ async fn test_client_updates_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: Some(CodeChatForWeb { @@ -848,7 +848,7 @@ async fn test_client_updates_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(4), + cursor_position: Some(CursorPosition::Line(4)), scroll_position: Some(1.0), is_re_translation: false, contents: Some(CodeChatForWeb { diff --git a/server/tests/overall_2.rs b/server/tests/overall_2.rs index 54a07d7d..1e6683da 100644 --- a/server/tests/overall_2.rs +++ b/server/tests/overall_2.rs @@ -58,8 +58,8 @@ use code_chat_editor::{ CodeChatForWeb, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata, StringDiff, }, webserver::{ - EditorMessage, EditorMessageContents, INITIAL_CLIENT_MESSAGE_ID, MESSAGE_ID_INCREMENT, - ResultOkTypes, UpdateMessageContents, set_root_path, + CursorPosition, EditorMessage, EditorMessageContents, INITIAL_CLIENT_MESSAGE_ID, + MESSAGE_ID_INCREMENT, ResultOkTypes, UpdateMessageContents, set_root_path, }, }; use test_utils::{cast, prep_test_dir}; @@ -112,7 +112,7 @@ async fn test_4_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: None, @@ -130,7 +130,7 @@ async fn test_4_core( &mut client_id, &mut client_version, "python", - Some(3), + Some(CursorPosition::Line(3)), Some(1.0), ) .await; @@ -142,7 +142,7 @@ async fn test_4_core( &mut client_id, &mut client_version, "python", - Some(5), + Some(CursorPosition::Line(5)), Some(1.0), ) .await; @@ -206,7 +206,7 @@ async fn test_5_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: None, @@ -232,7 +232,7 @@ async fn test_5_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: Some(CodeChatForWeb { @@ -295,7 +295,7 @@ async fn test_5_core( id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(1), + cursor_position: Some(CursorPosition::Line(1)), scroll_position: Some(1.0), is_re_translation: false, contents: Some(CodeChatForWeb { @@ -416,7 +416,6 @@ async fn test_6_core( message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), cursor_position: None, - includes_marker: false, scroll_position: None, is_re_translation: false, contents: None, diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index 287a03d6..39b0eab8 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -50,7 +50,7 @@ use code_chat_editor::{ ide::CodeChatEditorServer, processing::{CodeChatForWeb, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata}, webserver::{ - EditorMessage, EditorMessageContents, MESSAGE_ID_INCREMENT, ResultOkTypes, + CursorPosition, EditorMessage, EditorMessageContents, MESSAGE_ID_INCREMENT, ResultOkTypes, UpdateMessageContents, }, }; @@ -308,7 +308,7 @@ pub async fn goto_line( && let EditorMessageContents::Update(update) = &msg.message && update.file_path == path_str && update.contents.is_none() - && update.cursor_position != Some(line) + && update.cursor_position != Some(CursorPosition::Line(line)) { codechat_server.send_result(*client_id, None).await.unwrap(); *client_id += MESSAGE_ID_INCREMENT; @@ -320,7 +320,7 @@ pub async fn goto_line( id: *client_id, message: EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.to_string(), - cursor_position: Some(line), + cursor_position: Some(CursorPosition::Line(line)), scroll_position: Some(1.0), is_re_translation: false, contents: None, @@ -433,7 +433,7 @@ pub async fn get_empty_client_update( client_id: &mut f64, client_version: &mut f64, mode: &str, - cursor_position: Option, + cursor_position: Option, scroll_position: Option, ) { let msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); From 9842d83717b233091836a41af5cd9d016d835897 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 21 Apr 2026 16:57:57 +0500 Subject: [PATCH 5/9] wip: Claude, with comments manually restored. --- server/src/processing.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/server/src/processing.rs b/server/src/processing.rs index 07f9dbf7..4f37de7c 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -1062,13 +1062,43 @@ fn html_to_tree( .read_from(&mut html.as_bytes())?; if let Some(dom_offsets) = dom_offsets { - // TODO: each element in `dom_offsets` is the index of a node in the + // Each element in `dom_offsets` is the index of a node in the // `dom`. Take the first index, then descend into the indicated node. // Repeat this process until the last node, which should be a text node. // The last index is the offset with the text contents to insert a // `UNICODE_CURSOR_MARKER` character. Any failures (index exceeds number - // of nodes, etc.) should use an approximation where possible (use the - // last node). + // of nodes, etc.) use an approximation where possible. + let mut current_node = dom.document.clone(); + let last_idx = dom_offsets.len().saturating_sub(1); + 'outer: for (i, &offset) in dom_offsets.iter().enumerate() { + if i == last_idx { + // Insert the cursor marker at the given character offset within + // the text node. + if let NodeData::Text { contents } = ¤t_node.data { + let mut text = contents.borrow().to_string(); + // Convert the character offset into a byte offset. + let byte_offset = text + .char_indices() + .nth(offset) + .map(|(b, _)| b) + .unwrap_or(text.len()); + text.insert(byte_offset, UNICODE_CURSOR_MARKER); + *contents.borrow_mut() = text.into(); + } + } else { + let next_node = { + let children = current_node.children.borrow(); + if offset < children.len() { + children[offset].clone() + } else if let Some(last) = children.last() { + last.clone() + } else { + break 'outer; + } + }; + current_node = next_node; + } + } } Ok(dom.document) From 7f0448b979192f7c01bb2ea8cf85d5304888c0e2 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 21 Apr 2026 19:05:48 +0500 Subject: [PATCH 6/9] Fix: correct entry point (document body, not document). Refactor Add tests --- server/src/processing.rs | 31 +++++++++++++++++++++---------- server/src/processing/tests.rs | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/server/src/processing.rs b/server/src/processing.rs index 4f37de7c..54bd1ee1 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -602,6 +602,9 @@ fn doc_block_html_to_markdown( // If provided, the index of each successive node in the DOM, ending with // the offset within the last node (which must be a text node), at which a // marker character will be inserted. + // + // This will be applied to each doc block -- when this parameter is + // provided, it's typically called with a vec containing only one doc block. dom_offsets: &Option>, ) -> Result, HtmlToMarkdownWrappedError> { let mut converter = HtmlToMarkdownWrapped::new(); @@ -1062,13 +1065,13 @@ fn html_to_tree( .read_from(&mut html.as_bytes())?; if let Some(dom_offsets) = dom_offsets { - // Each element in `dom_offsets` is the index of a node in the - // `dom`. Take the first index, then descend into the indicated node. - // Repeat this process until the last node, which should be a text node. - // The last index is the offset with the text contents to insert a + // Each element in `dom_offsets` is the index of a node in the `dom`. + // Take the first index, then descend into the indicated node. Repeat + // this process until the last node, which should be a text node. The + // last index is the offset with the text contents to insert a // `UNICODE_CURSOR_MARKER` character. Any failures (index exceeds number // of nodes, etc.) use an approximation where possible. - let mut current_node = dom.document.clone(); + let mut current_node = get_dom_body(&dom.document); let last_idx = dom_offsets.len().saturating_sub(1); 'outer: for (i, &offset) in dom_offsets.iter().enumerate() { if i == last_idx { @@ -1117,6 +1120,18 @@ pub fn transform_html)>(html: &str, transform: T) -> io::Resu ..Default::default() }; let mut bytes = vec![]; + serialize( + &mut bytes, + &SerializableHandle::from(get_dom_body(&tree)), + so, + )?; + let html_out = String::from_utf8(bytes).map_err(io::Error::other)?; + + Ok(html_out) +} + +/// Get the body element from a top-level DOM. +fn get_dom_body(document: &Rc) -> Rc { // HTML is: // // ```html @@ -1125,11 +1140,7 @@ pub fn transform_html)>(html: &str, transform: T) -> io::Resu // ... <-- element 1 // // ``` - let body = tree.children.borrow()[0].children.borrow()[1].clone(); - serialize(&mut bytes, &SerializableHandle::from(body.clone()), so)?; - let html_out = String::from_utf8(bytes).map_err(io::Error::other)?; - - Ok(html_out) + document.children.borrow()[0].children.borrow()[1].clone() } // HTML produced from Markdown needs additional processing, termed hydration: diff --git a/server/src/processing/tests.rs b/server/src/processing/tests.rs index 4369c82c..de38fc10 100644 --- a/server/src/processing/tests.rs +++ b/server/src/processing/tests.rs @@ -24,7 +24,7 @@ use std::{io, path::PathBuf, rc::Rc, str::FromStr}; // ### Third-party -use indoc::indoc; +use indoc::{formatdoc, indoc}; use markup5ever_rcdom::Node; use predicates::prelude::predicate::str; use pretty_assertions::assert_eq; @@ -42,10 +42,10 @@ use crate::{ processing::{ CodeDocBlockVecToSourceError, CodeMirrorDiffable, CodeMirrorDocBlockDelete, CodeMirrorDocBlockTransaction, CodeMirrorDocBlockUpdate, CodechatForWebToSourceError, - HtmlToMarkdownWrapped, SourceToCodeChatForWebError, byte_index_of, + HtmlToMarkdownWrapped, SourceToCodeChatForWebError, UNICODE_CURSOR_MARKER, byte_index_of, code_doc_block_vec_to_source, code_mirror_to_code_doc_blocks, codechat_for_web_to_source, - dehydrating_walk_node, diff_code_mirror_doc_blocks, diff_str, html_to_tree, hydrate_html, - markdown_to_html, source_to_codechat_for_web, + dehydrating_walk_node, diff_code_mirror_doc_blocks, diff_str, doc_block_html_to_markdown, + html_to_tree, hydrate_html, markdown_to_html, source_to_codechat_for_web, }, }; use test_utils::{cast, prep_test_dir, test_utils::stringit}; @@ -1244,6 +1244,32 @@ fn test_diff_2() { ); } +#[test] +fn test_doc_block_html_to_markdown_1() { + assert_eq!( + doc_block_html_to_markdown( + vec![build_doc_block( + "", + "", + "

Index 0

Index 1.0Index 1.1012345

" + )], + &Some(vec![1, 2, 3]), + ) + .unwrap(), + vec![build_doc_block( + "", + "", + &formatdoc!( + " + Index 0 + + Index 1.0**Index 1.1**012{UNICODE_CURSOR_MARKER}345 + " + ) + )] + ); +} + #[test] fn test_hydrate_html_1() { // These tests check the translation from Markdown to "wet" HTML (what the user provides) instead of dry -> wet HTML. From df52361712250d5762475b7e9927fca9cba82c2f Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Wed, 22 Apr 2026 06:28:52 +0500 Subject: [PATCH 7/9] Fix: change Selenium testing to use generics instead of macros. --- server/tests/overall_1.rs | 87 ++++++------- server/tests/overall_2.rs | 52 +++----- server/tests/overall_common/mod.rs | 198 ++++++++++++++--------------- 3 files changed, 153 insertions(+), 184 deletions(-) diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index 88eb65cc..ea5b2c08 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -27,24 +27,13 @@ mod overall_common; // ------- // // ### Standard library -use std::{ - env, - error::Error, - panic::AssertUnwindSafe, - path::{Path, PathBuf}, - time::Duration, -}; +use std::{error::Error, path::PathBuf, time::Duration}; // ### Third-party -use assert_fs::TempDir; use dunce::canonicalize; -use futures::FutureExt; use indoc::indoc; use pretty_assertions::assert_eq; -use thirtyfour::{ - By, ChromiumLikeCapabilities, DesiredCapabilities, Key, WebDriver, error::WebDriverError, - start_webdriver_process, -}; +use thirtyfour::{By, Key, WebDriver, error::WebDriverError}; use tokio::time::sleep; // ### Local @@ -60,7 +49,7 @@ use code_chat_editor::{ }, webserver::{ CursorPosition, EditorMessage, EditorMessageContents, INITIAL_CLIENT_MESSAGE_ID, - MESSAGE_ID_INCREMENT, ResultOkTypes, UpdateMessageContents, set_root_path, + MESSAGE_ID_INCREMENT, ResultOkTypes, UpdateMessageContents, }, }; use test_utils::{cast, prep_test_dir}; @@ -79,8 +68,8 @@ make_test!(test_server, test_server_core); #[allow(deprecated)] async fn test_server_core( codechat_server: CodeChatEditorServer, - driver_ref: &WebDriver, - test_dir: &Path, + driver: WebDriver, + test_dir: PathBuf, ) -> Result<(), WebDriverError> { let mut expected_messages = ExpectedMessages::new(); let path = canonicalize(test_dir.join("test.py")).unwrap(); @@ -88,7 +77,7 @@ async fn test_server_core( let mut version = 1.0; let mut server_id = perform_loadfile( &codechat_server, - test_dir, + &test_dir, "test.py", Some(("# Test\ncode()".to_string(), version)), true, @@ -101,12 +90,12 @@ async fn test_server_core( // #### Doc block tests // // Verify the first doc block. - let codechat_iframe = select_codechat_iframe(driver_ref).await; + let codechat_iframe = select_codechat_iframe(&driver).await; let indent_css = ".CodeChat-CodeMirror .CodeChat-doc-indent"; - let doc_block_indent = driver_ref.find(By::Css(indent_css)).await.unwrap(); + let doc_block_indent = driver.find(By::Css(indent_css)).await.unwrap(); assert_eq!(doc_block_indent.inner_html().await.unwrap(), ""); let contents_css = ".CodeChat-CodeMirror .CodeChat-doc-contents"; - let doc_block_contents = driver_ref.find(By::Css(contents_css)).await.unwrap(); + let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); assert_eq!( doc_block_contents.inner_html().await.unwrap(), "

Test

\n" @@ -134,7 +123,7 @@ async fn test_server_core( client_id += MESSAGE_ID_INCREMENT; // Refind it, since it's now switched with a TinyMCE editor. - let tinymce_contents = driver_ref.find(By::Id("TinyMCE-inst")).await.unwrap(); + let tinymce_contents = driver.find(By::Id("TinyMCE-inst")).await.unwrap(); // Make an edit. tinymce_contents.send_keys("foo").await.unwrap(); @@ -215,7 +204,7 @@ async fn test_server_core( // // Verify the first line of code. let code_line_css = ".CodeChat-CodeMirror .cm-line"; - let code_line = driver_ref.find(By::Css(code_line_css)).await.unwrap(); + let code_line = driver.find(By::Css(code_line_css)).await.unwrap(); assert_eq!(code_line.inner_html().await.unwrap(), "code()"); // A click will update the current position and focus the code block. @@ -315,14 +304,14 @@ async fn test_server_core( ); // Verify them. - let doc_block_indent = driver_ref.find(By::Css(indent_css)).await.unwrap(); + let doc_block_indent = driver.find(By::Css(indent_css)).await.unwrap(); assert_eq!(doc_block_indent.inner_html().await.unwrap(), " "); - let doc_block_contents = driver_ref.find(By::Css(contents_css)).await.unwrap(); + let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); assert_eq!( doc_block_contents.inner_html().await.unwrap(), "

Testfood

" ); - let code_line = driver_ref.find(By::Css(code_line_css)).await.unwrap(); + let code_line = driver.find(By::Css(code_line_css)).await.unwrap(); assert_eq!(code_line.inner_html().await.unwrap(), "code()bark"); /*x TODO: these tests fail, since the Client sends an unnecessary OutOfSync message. How to test sending a diff to the client? @@ -359,7 +348,7 @@ async fn test_server_core( let toc_path = canonicalize(test_dir.join("toc.md")).unwrap(); server_id = perform_loadfile( &codechat_server, - test_dir, + &test_dir, "test.md", Some(("A **markdown** file.".to_string(), version)), true, @@ -369,7 +358,7 @@ async fn test_server_core( // Check the content. let body_css = "#CodeChat-body .CodeChat-doc-contents"; - let body_content = driver_ref.find(By::Css(body_css)).await.unwrap(); + let body_content = driver.find(By::Css(body_css)).await.unwrap(); assert_eq!( body_content.inner_html().await.unwrap(), "

A markdown file.

" @@ -521,7 +510,7 @@ async fn test_server_core( server_id += MESSAGE_ID_INCREMENT; // Look at the content, which should be an iframe. - let plain_content = driver_ref + let plain_content = driver .find(By::Css("#CodeChat-contents")) .await .unwrap(); @@ -539,13 +528,13 @@ async fn test_server_core( // #### PDF viewer // // Click on the link for the PDF to test. - let toc_iframe = driver_ref.find(By::Css("#CodeChat-sidebar")).await.unwrap(); - driver_ref + let toc_iframe = driver.find(By::Css("#CodeChat-sidebar")).await.unwrap(); + driver .switch_to() .frame_element(&toc_iframe) .await .unwrap(); - let test_pdf = driver_ref.find(By::LinkText("test.pdf")).await.unwrap(); + let test_pdf = driver.find(By::LinkText("test.pdf")).await.unwrap(); test_pdf.click().await.unwrap(); // Respond to the current file, then load requests for the PDf and the TOC. @@ -601,12 +590,12 @@ async fn test_server_core( // Check that the PDF viewer was sent. // // Target the iframe containing the Client. - driver_ref + driver .switch_to() .frame_element(&codechat_iframe) .await .unwrap(); - let plain_content = driver_ref + let plain_content = driver .find(By::Css("#CodeChat-contents")) .await .unwrap(); @@ -634,25 +623,25 @@ make_test!(test_client, test_client_core); #[allow(deprecated)] async fn test_client_core( codechat_server: CodeChatEditorServer, - driver_ref: &WebDriver, - test_dir: &Path, + driver: WebDriver, + test_dir: PathBuf, ) -> Result<(), WebDriverError> { let mut server_id = - perform_loadfile(&codechat_server, test_dir, "test.py", None, true, 6.0).await; + perform_loadfile(&codechat_server, &test_dir, "test.py", None, true, 6.0).await; let path = canonicalize(test_dir.join("test.py")).unwrap(); let path_str = path.to_str().unwrap().to_string(); // Target the iframe containing the Client. - let codechat_iframe = select_codechat_iframe(driver_ref).await; + let codechat_iframe = select_codechat_iframe(&driver).await; // Click on the link for the PDF to test. - let toc_iframe = driver_ref.find(By::Css("#CodeChat-sidebar")).await.unwrap(); - driver_ref + let toc_iframe = driver.find(By::Css("#CodeChat-sidebar")).await.unwrap(); + driver .switch_to() .frame_element(&toc_iframe) .await .unwrap(); - let test_py = driver_ref.find(By::LinkText("test.py")).await.unwrap(); + let test_py = driver.find(By::LinkText("test.py")).await.unwrap(); test_py.click().await.unwrap(); // Respond to the current file, then load requests for the PDF and the TOC. @@ -694,12 +683,12 @@ async fn test_client_core( sleep(Duration::from_millis(3000)).await; // Look for the test results. - driver_ref + driver .switch_to() .frame_element(&codechat_iframe) .await .unwrap(); - let mocha_results = driver_ref + let mocha_results = driver .find(By::Css("#mocha-stats .result")) .await .unwrap(); @@ -724,8 +713,8 @@ make_test!(test_client_updates, test_client_updates_core); async fn test_client_updates_core( codechat_server: CodeChatEditorServer, - driver_ref: &WebDriver, - test_dir: &Path, + driver: WebDriver, + test_dir: PathBuf, ) -> Result<(), WebDriverError> { let ide_version = 0.0; let orig_text = indoc!( @@ -739,7 +728,7 @@ async fn test_client_updates_core( .to_string(); let mut server_id = perform_loadfile( &codechat_server, - test_dir, + &test_dir, "test.py", Some((orig_text.clone(), ide_version)), true, @@ -751,11 +740,11 @@ async fn test_client_updates_core( let path_str = path.to_str().unwrap().to_string(); // Target the iframe containing the Client. - select_codechat_iframe(driver_ref).await; + select_codechat_iframe(&driver).await; // Select the doc block and add to the line, causing a word wrap. let contents_css = ".CodeChat-CodeMirror .CodeChat-doc-contents"; - let doc_block_contents = driver_ref.find(By::Css(contents_css)).await.unwrap(); + let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); doc_block_contents .send_keys("" + Key::End + " testing") .await @@ -830,13 +819,13 @@ async fn test_client_updates_core( ); server_id += MESSAGE_ID_INCREMENT; - goto_line(&codechat_server, driver_ref, &mut client_id, &path_str, 4) + goto_line(&codechat_server, &driver, &mut client_id, &path_str, 4) .await .unwrap(); // Add an indented comment. let code_line_css = ".CodeChat-CodeMirror .cm-line"; - let code_line = driver_ref.find(By::Css(code_line_css)).await.unwrap(); + let code_line = driver.find(By::Css(code_line_css)).await.unwrap(); code_line.send_keys(Key::Home + "# ").await.unwrap(); // This should edit the (new) third line of the file after word wrap: `def // foo():`. diff --git a/server/tests/overall_2.rs b/server/tests/overall_2.rs index 1e6683da..4a764a27 100644 --- a/server/tests/overall_2.rs +++ b/server/tests/overall_2.rs @@ -27,25 +27,13 @@ mod overall_common; // ------- // // ### Standard library -use std::{ - env, - error::Error, - panic::AssertUnwindSafe, - path::{Path, PathBuf}, - time::Duration, -}; +use std::{error::Error, path::PathBuf}; // ### Third-party -use assert_fs::TempDir; use dunce::canonicalize; -use futures::FutureExt; use indoc::indoc; use pretty_assertions::assert_eq; -use thirtyfour::{ - By, ChromiumLikeCapabilities, DesiredCapabilities, WebDriver, error::WebDriverError, - start_webdriver_process, -}; -use tokio::time::sleep; +use thirtyfour::{By, WebDriver, error::WebDriverError}; // ### Local use crate::overall_common::{ @@ -59,10 +47,10 @@ use code_chat_editor::{ }, webserver::{ CursorPosition, EditorMessage, EditorMessageContents, INITIAL_CLIENT_MESSAGE_ID, - MESSAGE_ID_INCREMENT, ResultOkTypes, UpdateMessageContents, set_root_path, + MESSAGE_ID_INCREMENT, ResultOkTypes, UpdateMessageContents, }, }; -use test_utils::{cast, prep_test_dir}; +use test_utils::prep_test_dir; make_test!(test_4, test_4_core); @@ -70,15 +58,15 @@ make_test!(test_4, test_4_core); // ----- async fn test_4_core( codechat_server: CodeChatEditorServer, - driver_ref: &WebDriver, - test_dir: &Path, + 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; perform_loadfile( &codechat_server, - test_dir, + &test_dir, "test.py", Some(( indoc!( @@ -99,12 +87,12 @@ async fn test_4_core( .await; // Target the iframe containing the Client. - select_codechat_iframe(driver_ref).await; + select_codechat_iframe(&driver).await; // Switch from one doc block to another. It should produce an update with // only cursor/scroll info (no contents). let mut client_id = INITIAL_CLIENT_MESSAGE_ID; - let doc_blocks = driver_ref.find_all(By::Css(".CodeChat-doc")).await.unwrap(); + let doc_blocks = driver.find_all(By::Css(".CodeChat-doc")).await.unwrap(); doc_blocks[0].click().await.unwrap(); assert_eq!( codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), @@ -157,8 +145,8 @@ make_test!(test_5, test_5_core); // Verify that newlines in Mermaid and Graphviz diagrams aren't removed. async fn test_5_core( codechat_server: CodeChatEditorServer, - driver_ref: &WebDriver, - test_dir: &Path, + 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(); @@ -182,7 +170,7 @@ async fn test_5_core( .to_string(); let mut server_id = perform_loadfile( &codechat_server, - test_dir, + &test_dir, "test.py", Some((orig_text.clone(), version)), false, @@ -191,11 +179,11 @@ async fn test_5_core( .await; // Target the iframe containing the Client. - select_codechat_iframe(driver_ref).await; + select_codechat_iframe(&driver).await; // Focus it. let contents_css = ".CodeChat-CodeMirror .CodeChat-doc-contents"; - let doc_block_contents = driver_ref.find(By::Css(contents_css)).await.unwrap(); + let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); doc_block_contents.click().await.unwrap(); // The click produces an updated cursor/scroll location after an autosave // delay. @@ -216,7 +204,7 @@ async fn test_5_core( client_id += MESSAGE_ID_INCREMENT; // Refind it, since it's now switched with a TinyMCE editor. - let tinymce_contents = driver_ref.find(By::Id("TinyMCE-inst")).await.unwrap(); + let tinymce_contents = driver.find(By::Id("TinyMCE-inst")).await.unwrap(); // Make an edit. tinymce_contents.send_keys("foo").await.unwrap(); @@ -341,8 +329,8 @@ make_test!(test_6, test_6_core); // Verify that edits in document-only mode don't result in data corruption. async fn test_6_core( codechat_server: CodeChatEditorServer, - driver_ref: &WebDriver, - test_dir: &Path, + driver: WebDriver, + test_dir: PathBuf, ) -> Result<(), WebDriverError> { let path = canonicalize(test_dir.join("test.md")).unwrap(); let path_str = path.to_str().unwrap().to_string(); @@ -357,7 +345,7 @@ async fn test_6_core( .to_string(); perform_loadfile( &codechat_server, - test_dir, + &test_dir, "test.md", Some((orig_text.clone(), version)), false, @@ -366,11 +354,11 @@ async fn test_6_core( .await; // Target the iframe containing the Client. - select_codechat_iframe(driver_ref).await; + select_codechat_iframe(&driver).await; // Check the content. let body_css = "#CodeChat-body .CodeChat-doc-contents"; - let body_content = driver_ref.find(By::Css(body_css)).await.unwrap(); + let body_content = driver.find(By::Css(body_css)).await.unwrap(); // Perform edits. body_content.send_keys("a").await.unwrap(); diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index 39b0eab8..caa0a31e 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -38,12 +38,24 @@ // ------- // // ### Standard library -use std::{collections::HashMap, error::Error, path::Path, time::Duration}; +use std::{ + collections::HashMap, + env, + error::Error, + panic::AssertUnwindSafe, + path::{Path, PathBuf}, + time::Duration, +}; +use assert_fs::TempDir; // ### Third-party use dunce::canonicalize; +use futures::FutureExt; use pretty_assertions::assert_eq; -use thirtyfour::{By, Key, WebDriver, WebElement}; +use thirtyfour::{ + By, ChromiumLikeCapabilities, DesiredCapabilities, Key, WebDriver, WebElement, + error::WebDriverError, start_webdriver_process, +}; // ### Local use code_chat_editor::{ @@ -51,10 +63,11 @@ use code_chat_editor::{ processing::{CodeChatForWeb, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata}, webserver::{ CursorPosition, EditorMessage, EditorMessageContents, MESSAGE_ID_INCREMENT, ResultOkTypes, - UpdateMessageContents, + UpdateMessageContents, set_root_path, }, }; use test_utils::cast; +use tokio::time::sleep; // Utilities // --------- @@ -135,125 +148,104 @@ pub const TIMEOUT: Duration = Duration::from_millis(2000); // The goal was to pass the harness a function which runs the tests. This // currently doesn't work, due to problems with lifetimes (see comments). So, // implement this as a macro instead (kludge!). -#[macro_export] -macro_rules! harness { - // The name of the test function to call inside the harness. - ($func: ident) => { - pub async fn harness< - 'a, - F: FnOnce(CodeChatEditorServer, &'a WebDriver, &'a Path) -> Fut, - Fut: Future>, - >( - // The function which performs tests using thirtyfour. TODO: not - // used. - _f: F, - // The output from calling `prep_test_dir!()`. - prep_test_dir: (TempDir, PathBuf), - ) -> Result<(), Box> { - let (temp_dir, test_dir) = prep_test_dir; - // The logger gets configured by (I think) - // `start_webdriver_process`, which delegates to `selenium-manager`. - // Set logging level here. - unsafe { env::set_var("RUST_LOG", "debug") }; - // Start the webdriver. - let server_url = "http://localhost:4444"; - let mut caps = DesiredCapabilities::chrome(); - // Ensure the screen is wide enough for an 80-character line, used - // to word wrapping test in `test_client_updates`. Otherwise, this - // test send the End 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("--headless")?; - // On Ubuntu CI, avoid failures, probably due to running Chrome as - // root. - #[cfg(target_os = "linux")] - if env::var("CI") == Ok("true".to_string()) { - caps.add_arg("--disable-gpu")?; - caps.add_arg("--no-sandbox")?; - } - if let Err(err) = start_webdriver_process(server_url, &caps, true) { - // Often, the "failure" is that the webdriver is already - // running. - eprintln!("Failed to start the webdriver process: {err:#?}"); - } - // Wait for the driver to start up. - sleep(Duration::from_millis(500)).await; - let driver = WebDriver::new(server_url, caps).await?; - let driver_clone = driver.clone(); - let driver_ref = &driver_clone; +pub async fn harness< + F: FnOnce(CodeChatEditorServer, WebDriver, PathBuf) -> Fut, + Fut: Future>, +>( + f: F, + // The output from calling `prep_test_dir!()`. + prep_test_dir: (TempDir, PathBuf), +) -> Result<(), Box> { + let (temp_dir, test_dir) = prep_test_dir; + // The logger gets configured by (I think) + // `start_webdriver_process`, which delegates to `selenium-manager`. + // Set logging level here. + unsafe { env::set_var("RUST_LOG", "debug") }; + // Start the webdriver. + let server_url = "http://localhost:4444"; + let mut caps = DesiredCapabilities::chrome(); + // Ensure the screen is wide enough for an 80-character line, used + // to word wrapping test in `test_client_updates`. Otherwise, this + // test send the End 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("--headless")?; + // On Ubuntu CI, avoid failures, probably due to running Chrome as + // root. + #[cfg(target_os = "linux")] + if env::var("CI") == Ok("true".to_string()) { + caps.add_arg("--disable-gpu")?; + caps.add_arg("--no-sandbox")?; + } + if let Err(err) = start_webdriver_process(server_url, &caps, true) { + // Often, the "failure" is that the webdriver is already + // running. + eprintln!("Failed to start the webdriver process: {err:#?}"); + } + // Wait for the driver to start up. + sleep(Duration::from_millis(500)).await; + let driver = WebDriver::new(server_url, caps).await?; + let driver_clone = driver.clone(); - // Run the test inside an async, so we can shut down the driver - // before returning an error. Mark the function as unwind safe. - // though I'm not certain this is correct. Hopefully, it's good - // enough for testing. - let ret = AssertUnwindSafe(async move { - // ### Setup - let p = env::current_exe().unwrap().parent().unwrap().join("../.."); - set_root_path(Some(&p)).unwrap(); - let codechat_server = CodeChatEditorServer::new().unwrap(); + // Run the test inside an async, so we can shut down the driver + // before returning an error. Mark the function as unwind safe. + // though I'm not certain this is correct. Hopefully, it's good + // enough for testing. + let ret = AssertUnwindSafe(async move { + // ### Setup + let p = env::current_exe().unwrap().parent().unwrap().join("../.."); + set_root_path(Some(&p)).unwrap(); + let codechat_server = CodeChatEditorServer::new().unwrap(); - // Get the resulting web page text. - let opened_id = codechat_server.send_message_opened(true).await.unwrap(); - pretty_assertions::assert_eq!( - codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), - EditorMessage { - id: opened_id, - message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)) - } - ); - let em_html = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); - codechat_server.send_result(em_html.id, None).await.unwrap(); + // Get the resulting web page text. + let opened_id = codechat_server.send_message_opened(true).await.unwrap(); + pretty_assertions::assert_eq!( + codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), + EditorMessage { + id: opened_id, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)) + } + ); + let em_html = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); + codechat_server.send_result(em_html.id, None).await.unwrap(); - // Parse out the address to use. - let client_html = cast!(&em_html.message, EditorMessageContents::ClientHtml); - let find_str = "