From f20c9258c95d3edb2ef5403cf4b411d0eedf0d7e Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 23 Jun 2026 17:32:48 +0500 Subject: [PATCH 01/13] Add: report JS console output during WebDriver tests. --- server/tests/overall_1.rs | 11 +- server/tests/overall_2.rs | 11 +- server/tests/overall_3.rs | 9 +- server/tests/overall_common/mod.rs | 166 ++++++++++++++++++++++++++--- 4 files changed, 166 insertions(+), 31 deletions(-) diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index 601d5f17..4c6fc8a9 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -38,11 +38,10 @@ use tokio::time::sleep; // ### Local use crate::overall_common::{ - ExpectedMessages, TIMEOUT, assert_no_more_messages, get_version, goto_line, optional_message, - perform_loadfile, select_codechat_iframe, + CodeChatEditorServerLog, ExpectedMessages, TIMEOUT, assert_no_more_messages, get_version, + goto_line, optional_message, perform_loadfile, select_codechat_iframe, }; use code_chat_editor::{ - ide::CodeChatEditorServer, lexer::supported_languages::MARKDOWN_MODE, processing::{ CodeChatForWeb, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata, StringDiff, @@ -67,7 +66,7 @@ make_test!(test_server, test_server_core); // marked that way in the Selenium docs. #[allow(deprecated)] async fn test_server_core( - codechat_server: CodeChatEditorServer, + codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, ) -> Result<(), WebDriverError> { @@ -654,7 +653,7 @@ make_test!(test_client, test_client_core); // marked that way in the Selenium docs. #[allow(deprecated)] async fn test_client_core( - codechat_server: CodeChatEditorServer, + codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, ) -> Result<(), WebDriverError> { @@ -733,7 +732,7 @@ async fn test_client_core( make_test!(test_client_updates, test_client_updates_core); async fn test_client_updates_core( - codechat_server: CodeChatEditorServer, + codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, ) -> Result<(), WebDriverError> { diff --git a/server/tests/overall_2.rs b/server/tests/overall_2.rs index 810481b9..5aff13ba 100644 --- a/server/tests/overall_2.rs +++ b/server/tests/overall_2.rs @@ -37,11 +37,10 @@ use thirtyfour::{By, Key, WebDriver, error::WebDriverError}; // ### Local use crate::overall_common::{ - TIMEOUT, assert_no_more_messages, click_element_top_left, get_version, optional_message, - perform_loadfile, select_codechat_iframe, + CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, click_element_top_left, get_version, + optional_message, perform_loadfile, select_codechat_iframe, }; use code_chat_editor::{ - ide::CodeChatEditorServer, processing::{ CodeChatForWeb, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata, StringDiff, }, @@ -57,7 +56,7 @@ make_test!(test_4, test_4_core); // Tests // ----- async fn test_4_core( - codechat_server: CodeChatEditorServer, + codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, ) -> Result<(), WebDriverError> { @@ -154,7 +153,7 @@ make_test!(test_5, test_5_core); // Verify that newlines in Mermaid and Graphviz diagrams aren't removed, and // that equations aren't munged. async fn test_5_core( - codechat_server: CodeChatEditorServer, + codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, ) -> Result<(), WebDriverError> { @@ -341,7 +340,7 @@ 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, + codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, ) -> Result<(), WebDriverError> { diff --git a/server/tests/overall_3.rs b/server/tests/overall_3.rs index 7b9dfdd2..eac06b9a 100644 --- a/server/tests/overall_3.rs +++ b/server/tests/overall_3.rs @@ -37,11 +37,10 @@ use thirtyfour::{By, Key, WebDriver, error::WebDriverError}; // ### Local use crate::overall_common::{ - TIMEOUT, assert_no_more_messages, click_element_top_left, get_version, optional_message, - perform_loadfile, select_codechat_iframe, + CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, click_element_top_left, get_version, + optional_message, perform_loadfile, select_codechat_iframe, }; use code_chat_editor::{ - ide::CodeChatEditorServer, processing::{ CodeChatForWeb, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata, StringDiff, }, @@ -58,7 +57,7 @@ make_test!(test_7, test_7_core); // Test that Client to IDE cursor sync in doc blocks works. async fn test_7_core( - codechat_server: CodeChatEditorServer, + codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, ) -> Result<(), WebDriverError> { @@ -145,7 +144,7 @@ make_test!(test_8, test_8_core); // Test that Clients can insert a new paragraph. async fn test_8_core( - codechat_server: CodeChatEditorServer, + codechat_server: CodeChatEditorServerLog, driver: WebDriver, test_dir: PathBuf, ) -> Result<(), WebDriverError> { diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index 4cdafdbe..43538ee5 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -53,10 +53,10 @@ use dunce::canonicalize; use futures::FutureExt; use pretty_assertions::assert_eq; use thirtyfour::{ - By, ChromiumLikeCapabilities, DesiredCapabilities, Key, WebDriver, WebElement, - error::WebDriverError, + By, ChromiumLikeCapabilities, DesiredCapabilities, Key, LoggingPrefsLogLevel, WebDriver, + WebElement, error::WebDriverError, }; -use tracing::debug; +use tracing::{debug, error, info, warn}; use tracing_log::LogTracer; use tracing_subscriber::EnvFilter; @@ -64,12 +64,99 @@ use tracing_subscriber::EnvFilter; use code_chat_editor::{ ide::CodeChatEditorServer, webserver::{ - CursorPosition, EditorMessage, EditorMessageContents, MESSAGE_ID_INCREMENT, ResultOkTypes, - UpdateMessageContents, set_root_path, + CursorPosition, EditorMessage, EditorMessageContents, MESSAGE_ID_INCREMENT, ResultErrTypes, + ResultOkTypes, UpdateMessageContents, set_root_path, }, }; use test_utils::cast; +// Console-log-polling server wrapper +// ---------------------------------- +// +// The legacy `/log` "browser" buffer (where chromedriver collects page-side +// `console.*` output and uncaught JavaScript errors) is only drained when we +// ask for it. To interleave that output with the rest of the test's logging, +// this wrapper holds the `CodeChatEditorServer` together with a `WebDriver` +// handle and drains the buffer on every call the test framework makes. Each +// delegated method forwards to the inner server and, around that call, polls +// the browser log via [`forward_browser_logs`]. +// +// The wrapper exposes the same method names as `CodeChatEditorServer`, so +// test bodies use it transparently. +pub struct CodeChatEditorServerLog { + inner: CodeChatEditorServer, + // A `WebDriver` handle used only to read the browser log. `WebDriver` is + // cheap to clone (it's an `Arc` internally), so a clone of the harness's + // driver is stored here. + driver: WebDriver, +} + +impl CodeChatEditorServerLog { + pub fn new(inner: CodeChatEditorServer, driver: WebDriver) -> CodeChatEditorServerLog { + CodeChatEditorServerLog { inner, driver } + } + + // Drain and forward any console output the browser has produced so far. + async fn poll_log(&self) { + forward_browser_logs(&self.driver).await; + } + + // The following methods mirror `CodeChatEditorServer`'s API. Each polls + // the browser log so console output is emitted close to when it occurred, + // then delegates to the inner server. + pub async fn get_message_timeout(&self, timeout: Duration) -> Option { + let msg = self.inner.get_message_timeout(timeout).await; + // Waiting for a message is when the browser is most active, so poll + // again after the wait to catch output produced during it. + self.poll_log().await; + msg + } + + pub async fn send_message_opened(&self, hosted_in_ide: bool) -> std::io::Result { + self.poll_log().await; + self.inner.send_message_opened(hosted_in_ide).await + } + + pub async fn send_message_current_file(&self, url: String) -> std::io::Result { + self.poll_log().await; + self.inner.send_message_current_file(url).await + } + + // Used by some test targets but not others; each test binary compiles + // this module separately, so it's dead code in the others. + #[allow(dead_code)] + pub async fn send_message_update_plain( + &self, + file_path: String, + option_contents: Option<(String, f64)>, + cursor_position: Option, + scroll_position: Option, + ) -> std::io::Result { + self.poll_log().await; + self.inner + .send_message_update_plain(file_path, option_contents, cursor_position, scroll_position) + .await + } + + pub async fn send_result( + &self, + id: f64, + message_result: Option, + ) -> std::io::Result<()> { + self.poll_log().await; + self.inner.send_result(id, message_result).await + } + + pub async fn send_result_loadfile( + &self, + id: f64, + load_file: Option<(String, f64)>, + ) -> std::io::Result<()> { + self.poll_log().await; + self.inner.send_result_loadfile(id, load_file).await + } +} + // Utilities // --------- // @@ -122,13 +209,17 @@ impl ExpectedMessages { } } - async fn _assert_message(&mut self, codechat_server: &CodeChatEditorServer, timeout: Duration) { + async fn _assert_message( + &mut self, + codechat_server: &CodeChatEditorServerLog, + timeout: Duration, + ) { self.check(codechat_server.get_message_timeout(timeout).await.unwrap()); } pub async fn assert_all_messages( &mut self, - codechat_server: &CodeChatEditorServer, + codechat_server: &CodeChatEditorServerLog, timeout: Duration, ) { while !self.0.is_empty() { @@ -153,7 +244,7 @@ pub const TIMEOUT: Duration = Duration::from_millis(3000); // runs provided tests. After testing finishes, it cleans up (handling panics // properly). pub async fn harness< - F: FnOnce(CodeChatEditorServer, WebDriver, PathBuf) -> Fut, + F: FnOnce(CodeChatEditorServerLog, WebDriver, PathBuf) -> Fut, Fut: Future>, >( f: F, @@ -181,6 +272,11 @@ pub async fn harness< // 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. caps.add_arg("--headless")?; // On Ubuntu CI, avoid failures, probably due to running Chrome as root. @@ -200,7 +296,12 @@ pub async fn harness< // ### Setup let p = env::current_exe().unwrap().parent().unwrap().join("../.."); set_root_path(Some(&p)).unwrap(); - let codechat_server = CodeChatEditorServer::new().unwrap(); + // Wrap the server so every call the test framework makes also drains + // the browser's JavaScript console log (see `CodeChatEditorServerLog`). + let codechat_server = CodeChatEditorServerLog::new( + CodeChatEditorServer::new().unwrap(), + driver_clone.clone(), + ); // Get the resulting web page text. let opened_id = codechat_server.send_message_opened(true).await.unwrap(); @@ -223,8 +324,15 @@ pub async fn harness< // Open the Client and send it a file to load. driver_clone.goto(address).await.unwrap(); - f(codechat_server, driver_clone, test_dir).await?; + let test_result = f(codechat_server, driver_clone.clone(), test_dir).await; + // Drain any JavaScript console output captured during the test and + // forward it to Rust logging, then propagate the test's result. Do + // this even when the test failed, since the console output often + // explains the failure. + forward_browser_logs(&driver_clone).await; + + test_result?; Ok(()) }) // Catch any panics/assertions, again to ensure the driver shuts down @@ -244,6 +352,36 @@ pub async fn harness< )))) } +/// Drain the browser's `console.*` / uncaught-error log buffer and re-emit +/// each entry through the Rust `tracing` macros, mapping the Selenium log +/// level to the closest Rust log level. Requires +/// `set_browser_log_level(...)` to have been set on the capabilities used to +/// start the driver (see `harness`). +/// +/// Errors fetching the log are ignored: `get_log("browser")` is a legacy, +/// non-W3C endpoint, and a failure to read it should never fail a test. +async fn forward_browser_logs(driver: &WebDriver) { + let entries = match driver.get_log("browser").await { + Ok(entries) => entries, + Err(err) => { + debug!("Unable to read browser console log: {err}"); + return; + } + }; + for entry in entries { + // chromedriver emits SCREAMING levels (`SEVERE`, `WARNING`, `INFO`, + // `DEBUG`, `FINE`, ...). Map them onto Rust log levels. + let msg = format!("JS console [{}]: {}", entry.level, entry.message); + match entry.level.as_str() { + "SEVERE" => error!("{msg}"), + "WARNING" => warn!("{msg}"), + "INFO" | "CONFIG" => info!("{msg}"), + // FINE/FINER/FINEST/DEBUG and anything else. + _ => debug!("{msg}"), + } + } +} + #[macro_export] macro_rules! make_test { // The name of the test function to call inside the harness. @@ -270,7 +408,7 @@ pub fn get_version(msg: &EditorMessage) -> f64 { #[allow(dead_code)] #[tracing::instrument(skip_all)] pub async fn goto_line( - codechat_server: &CodeChatEditorServer, + codechat_server: &CodeChatEditorServerLog, driver_ref: &WebDriver, client_id: &mut f64, path_str: &str, @@ -338,7 +476,7 @@ pub async fn goto_line( } pub async fn perform_loadfile( - codechat_server: &CodeChatEditorServer, + codechat_server: &CodeChatEditorServerLog, test_dir: &Path, file_name: &str, file_contents: Option<(String, f64)>, @@ -450,7 +588,7 @@ pub async fn select_codechat_iframe(driver_ref: &WebDriver) -> WebElement { codechat_iframe } -pub async fn assert_no_more_messages(codechat_server: &CodeChatEditorServer) { +pub async fn assert_no_more_messages(codechat_server: &CodeChatEditorServerLog) { if let Some(msg) = codechat_server .get_message_timeout(Duration::from_millis(500)) .await @@ -465,7 +603,7 @@ pub async fn assert_no_more_messages(codechat_server: &CodeChatEditorServer) { #[allow(dead_code)] #[tracing::instrument(skip_all)] pub async fn optional_message( - codechat_server: &CodeChatEditorServer, + codechat_server: &CodeChatEditorServerLog, client_id: &mut f64, optional_message: EditorMessageContents, ) -> EditorMessage { From 09e91d793a95132688eb2c6e4bfe4305be9a90e3 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 23 Jun 2026 17:33:51 +0500 Subject: [PATCH 02/13] Add: test to reveal errors with adjacent doc blocks. --- .../tests/fixtures/overall_3/test_9/test.py | 3 + server/tests/overall_3.rs | 171 +++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 server/tests/fixtures/overall_3/test_9/test.py diff --git a/server/tests/fixtures/overall_3/test_9/test.py b/server/tests/fixtures/overall_3/test_9/test.py new file mode 100644 index 00000000..23103ab8 --- /dev/null +++ b/server/tests/fixtures/overall_3/test_9/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_3.rs b/server/tests/overall_3.rs index eac06b9a..1d5f943f 100644 --- a/server/tests/overall_3.rs +++ b/server/tests/overall_3.rs @@ -33,7 +33,7 @@ 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, Key, WebDriver, error::WebDriverError, extensions::query::ElementQueryable}; // ### Local use crate::overall_common::{ @@ -468,3 +468,172 @@ async fn test_8_core( Ok(()) } + +make_test!(test_9, test_9_core); + +// Test that Clients can insert a new paragraph. +async fn test_9_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; + perform_loadfile( + &codechat_server, + &test_dir, + "test.py", + Some(( + indoc!( + " + # 1 + # 2 + " + ) + .to_string(), + ide_version, + )), + false, + 6.0, + ) + .await; + + // Target the iframe containing the Client. + select_codechat_iframe(&driver).await; + + // Focus the doc block. It should produce an update with only cursor/scroll + // info (no contents). + let mut client_id = INITIAL_CLIENT_MESSAGE_ID; + let doc_block = driver + .query(By::Css(".CodeChat-doc")) + .first() + .await + .unwrap(); + click_element_top_left(&driver, &doc_block).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; + + // Refind it, since it's now switched with a TinyMCE editor. + let tinymce_contents = driver.find(By::Id("TinyMCE-inst")).await.unwrap(); + + // Perform an edit + tinymce_contents.send_keys("a").await.unwrap(); + + let msg = optional_message( + &codechat_server, + &mut client_id, + 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, + }), + ) + .await; + let mut version = 0.0; + let client_version = get_version(&msg); + assert_eq!( + msg, + 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: Some(CodeChatForWeb { + metadata: SourceFileMetadata { + mode: "python".to_string(), + }, + source: CodeMirrorDiffable::Diff(CodeMirrorDiff { + doc: vec![StringDiff { + from: 0, + to: Some(4), + insert: "# a1\n".to_string(), + },], + doc_blocks: vec![], + version, + }), + version: client_version, + }), + }) + } + ); + version = client_version; + codechat_server.send_result(client_id, None).await.unwrap(); + client_id += MESSAGE_ID_INCREMENT; + + // Focus on the code block. + let cm_line = driver.query(By::Css(".cm-line")).first().await.unwrap(); + cm_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; + + // Add a character. + cm_line.send_keys("3").await.unwrap(); + let msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); + let client_version = get_version(&msg); + assert_eq!( + msg, + 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: Some(CodeChatForWeb { + metadata: SourceFileMetadata { + mode: "python".to_string(), + }, + source: CodeMirrorDiffable::Diff(CodeMirrorDiff { + doc: vec![StringDiff { + from: 10, + to: None, + insert: "3".to_string(), + },], + doc_blocks: vec![], + version, + }), + version: client_version, + }), + }) + } + ); + codechat_server.send_result(client_id, None).await.unwrap(); + //client_id += MESSAGE_ID_INCREMENT; + + assert_no_more_messages(&codechat_server).await; + + Ok(()) +} From 688b97839f2e6fa76e5a8f6a6fff18c45c1f3899 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Wed, 24 Jun 2026 20:20:05 +0500 Subject: [PATCH 03/13] Fix: better diags, webdriver fixes. --- builder/src/main.rs | 1 + server/tests/overall_2.rs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/builder/src/main.rs b/builder/src/main.rs index 18479638..c55e855b 100644 --- a/builder/src/main.rs +++ b/builder/src/main.rs @@ -270,6 +270,7 @@ fn copy_file + std::fmt::Debug>(src: P, dest: P) -> io::Result<() } fn remove_dir_all_if_exists + std::fmt::Display>(path: P) -> io::Result<()> { + println!("Removing {path}..."); if Path::new(path.as_ref()).try_exists().unwrap() { fs::remove_dir_all(path.as_ref())?; } diff --git a/server/tests/overall_2.rs b/server/tests/overall_2.rs index 5aff13ba..2acd2322 100644 --- a/server/tests/overall_2.rs +++ b/server/tests/overall_2.rs @@ -110,8 +110,20 @@ async fn test_4_core( client_id += MESSAGE_ID_INCREMENT; doc_blocks[1].click().await.unwrap(); + let msg = optional_message( + &codechat_server, + &mut client_id, + 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, + }), + ) + .await; assert_eq!( - codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), + msg, EditorMessage { id: client_id, message: EditorMessageContents::Update(UpdateMessageContents { From 68451aee423cf36c9808e9fe8e71eef5db414104 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Wed, 24 Jun 2026 20:31:01 +0500 Subject: [PATCH 04/13] Add: linting for CSS files. --- client/eslint.config.js | 25 ++++++++++++++++++++----- client/package.json5 | 1 + client/src/css/CodeChatEditor.css | 2 +- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/client/eslint.config.js b/client/eslint.config.js index 37e169c0..3119990a 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -19,15 +19,21 @@ // `.eslintrc.yml` -- Configure ESLint for this project // ==================================================== import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import css from "@eslint/css"; import eslint from "@eslint/js"; import { defineConfig } from "eslint/config"; import tseslint from "typescript-eslint"; import globals from "globals"; +// Glob matching the JS/TS files the JavaScript/TypeScript configs below should +// apply to. Without this, those configs (and their core rules) also run against +// `.css` files, which crashes since CSS uses a different language. +const jsFiles = ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"]; + export default defineConfig( - eslint.configs.recommended, - tseslint.configs.recommended, - eslintPluginPrettierRecommended, + { files: jsFiles, extends: [eslint.configs.recommended] }, + { files: jsFiles, extends: [tseslint.configs.recommended] }, + { files: jsFiles, extends: [eslintPluginPrettierRecommended] }, defineConfig([ { // This must be the only key in this dict to be treated as a global @@ -37,10 +43,11 @@ export default defineConfig( }, { name: "local", + files: jsFiles, languageOptions: { globals: { - ...globals.browser - } + ...globals.browser, + }, }, rules: { "no-unused-vars": "off", @@ -58,5 +65,13 @@ export default defineConfig( ], }, }, + { + name: "css", + files: ["**/*.css"], + ignores: ["src/third-party/**"], + language: "css/css", + plugins: { css }, + extends: ["css/recommended"], + }, ]), ); diff --git a/client/package.json5 b/client/package.json5 index aa960b08..d7885950 100644 --- a/client/package.json5 +++ b/client/package.json5 @@ -74,6 +74,7 @@ 'toastify-js': '^1.12.0', }, devDependencies: { + '@eslint/css': '^1.3.0', '@eslint/js': '^10.0.1', '@types/chai': '^5.2.3', '@types/dom-navigation': '^1.0.7', diff --git a/client/src/css/CodeChatEditor.css b/client/src/css/CodeChatEditor.css index e68dccd2..7f284f05 100644 --- a/client/src/css/CodeChatEditor.css +++ b/client/src/css/CodeChatEditor.css @@ -151,7 +151,7 @@ body { /* Reset what CodeMirror messes up for doc blocks. */ .CodeChat-doc-contents { - font-family: auto; + font-family: inherit; line-height: initial; white-space: normal; flex-grow: 1; From d710f76b4bfa67a1ea0799023f2251e4207bc36a Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Wed, 24 Jun 2026 22:10:38 +0500 Subject: [PATCH 05/13] Add: ammonia. --- CHANGELOG.md | 2 +- server/Cargo.toml | 1 + server/src/processing.rs | 52 +++++++++++++++++++++++++++++----- server/src/processing/tests.rs | 15 +++++++--- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eb1c258..9f792cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Changelog [Github master](https://github.com/bjones1/CodeChat_Editor) ----------------------------------------------------------- -* No changes. +* Block malicious HTML in source code/Markdown documents. Version 0.1.58 -- 2026-Jun-22 ----------------------------- diff --git a/server/Cargo.toml b/server/Cargo.toml index aa7879e6..3baee167 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -49,6 +49,7 @@ actix-service = "2.0.3" actix-web = "4" actix-web-httpauth = "0.8.2" actix-ws = "0.4" +ammonia = "4.1.2" anyhow = "1.0.100" bytes = { version = "1", features = ["serde"] } chrono = "0.4" diff --git a/server/src/processing.rs b/server/src/processing.rs index 150c0026..b8e4136e 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -37,6 +37,7 @@ use std::{ }; // ### Third-party +use ammonia::Builder; use dprint_plugin_markdown::{ configuration::{ Configuration, ConfigurationBuilder, EmphasisKind, HeadingKind, StrongKind, TextWrap, @@ -992,9 +993,43 @@ static MINIFY_OPTIONS: LazyLock = LazyLock::new(|| { cfg }); +// A static config for Ammonia. +static AMMONIA_OPTIONS: LazyLock = LazyLock::new(|| { + let mut b = Builder::default(); + // Add custom tags produced during hydration, plus `input` (task list + // 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"]) + // This allows math produced by pulldown-cmark and updated by the + // hydration code. + .add_allowed_classes( + "span", + &["math", "math-inline", "math-display", "mceNonEditable"], + ) + // `code` tags can have `class=language-*`. Since Ammonia doesn't + // support a regex like this, just allow anything. + .add_tag_attributes("code", &["class"]) + // Task list checkboxes are rendered as ``. + .add_tag_attributes("input", &["type", "checked", "disabled"]) + // Allow the attributes TinyMCE/the IDE place on embedded `