From 7b850769c9591b11a91d6ee6875a381fc89fafc9 Mon Sep 17 00:00:00 2001 From: Per Stark Date: Wed, 3 Jun 2026 20:20:43 +0200 Subject: [PATCH] fix: html-router modals and add insta snapshot tests. Avoid nested forms in the scratchpad editor, centralize modal lifecycle in modal.js, return HTMX partials from archive, and add template compile plus layout snapshots. --- .gitignore | 4 + Cargo.lock | 33 +- html-router/Cargo.toml | 1 + html-router/assets/admin-prompt-reset.js | 19 + html-router/assets/modal.js | 33 ++ html-router/src/routes/scratchpad/handlers.rs | 4 +- .../admin/edit_image_prompt_modal.html | 9 +- .../admin/edit_ingestion_prompt_modal.html | 9 +- .../admin/edit_query_prompt_modal.html | 9 +- html-router/templates/body_base.html | 31 ++ .../chat/new_chat_first_response.html | 24 +- .../templates/chat/new_message_form.html | 21 +- .../templates/chat/reference_list.html | 30 +- .../templates/chat/streaming_response.html | 55 +-- .../templates/components/_sidebar_layout.html | 86 ++-- html-router/templates/head_base.html | 50 +-- .../knowledge/relationship_table.html | 11 +- html-router/templates/modal_base.html | 30 +- .../templates/scratchpad/editor_modal.html | 14 +- html-router/templates/sidebar.html | 2 +- html-router/tests/router_integration.rs | 233 +++++++++++ .../tests/snapshots/authenticated_shell.snap | 391 ++++++++++++++++++ .../tests/snapshots/dashboard_main.snap | 91 ++++ .../tests/snapshots/new_entity_modal.snap | 119 ++++++ .../tests/snapshots/not_found_main.snap | 17 + html-router/tests/snapshots/search_main.snap | 46 +++ html-router/tests/snapshots/signin_page.snap | 103 +++++ html-router/tests/snapshots/signup_page.snap | 108 +++++ html-router/tests/template_load.rs | 60 +++ 29 files changed, 1426 insertions(+), 217 deletions(-) create mode 100644 html-router/assets/admin-prompt-reset.js create mode 100644 html-router/assets/modal.js create mode 100644 html-router/tests/snapshots/authenticated_shell.snap create mode 100644 html-router/tests/snapshots/dashboard_main.snap create mode 100644 html-router/tests/snapshots/new_entity_modal.snap create mode 100644 html-router/tests/snapshots/not_found_main.snap create mode 100644 html-router/tests/snapshots/search_main.snap create mode 100644 html-router/tests/snapshots/signin_page.snap create mode 100644 html-router/tests/snapshots/signup_page.snap create mode 100644 html-router/tests/template_load.rs diff --git a/.gitignore b/.gitignore index b9de9c3..8ea34eb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ devenv.local.nix # html-router/assets/style.css html-router/node_modules .fastembed_cache/ + +# insta: pending (unreviewed) snapshots; accepted *.snap files are committed +*.snap.new +.insta.bak diff --git a/Cargo.lock b/Cargo.lock index bb68cf7..0f6ca8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1468,6 +1468,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "const-random" version = "0.1.18" @@ -2967,6 +2978,7 @@ dependencies = [ "common", "futures", "include_dir", + "insta", "json-stream-parser", "minijinja", "minijinja-autoreload", @@ -3342,7 +3354,7 @@ version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" dependencies = [ - "console", + "console 0.15.11", "number_prefix", "portable-atomic", "unicode-width 0.2.2", @@ -3412,6 +3424,19 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console 0.16.3", + "once_cell", + "regex", + "similar", + "tempfile", +] + [[package]] name = "instant" version = "0.1.13" @@ -6164,6 +6189,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simple_asn1" version = "0.6.4" diff --git a/html-router/Cargo.toml b/html-router/Cargo.toml index 6eddc96..1d231e6 100644 --- a/html-router/Cargo.toml +++ b/html-router/Cargo.toml @@ -43,6 +43,7 @@ json-stream-parser = { path = "../json-stream-parser" } [dev-dependencies] common = { path = "../common", features = ["test-utils"] } +insta = { version = "1.47.2", features = ["filters"] } tower = "0.5" [build-dependencies] diff --git a/html-router/assets/admin-prompt-reset.js b/html-router/assets/admin-prompt-reset.js new file mode 100644 index 0000000..af6ffde --- /dev/null +++ b/html-router/assets/admin-prompt-reset.js @@ -0,0 +1,19 @@ +/** + * Shared "Reset to Default" handler for the admin prompt-edit modals + * (templates/admin/edit_*_prompt_modal.html). + * + * Each reset button carries data-reset-target with a selector for the prompt + * textarea to repopulate from the modal's hidden #default_prompt_content. + */ +(function () { + 'use strict'; + + document.body.addEventListener('click', function (e) { + const btn = e.target.closest('[data-reset-target]'); + if (!btn) return; + const scope = btn.closest('dialog') || document; + const source = scope.querySelector('#default_prompt_content'); + const target = scope.querySelector(btn.dataset.resetTarget); + if (source && target) target.value = source.value; + }); +})(); diff --git a/html-router/assets/modal.js b/html-router/assets/modal.js new file mode 100644 index 0000000..6db1e80 --- /dev/null +++ b/html-router/assets/modal.js @@ -0,0 +1,33 @@ +/** + * Modal lifecycle for markup injected into #modal (see templates/modal_base.html). + * + * Uses delegated listeners so we do not rely on inline - - @@ -13,24 +15,8 @@ diff --git a/html-router/templates/chat/reference_list.html b/html-router/templates/chat/reference_list.html index cb8e667..0b907e3 100644 --- a/html-router/templates/chat/reference_list.html +++ b/html-router/templates/chat/reference_list.html @@ -1,7 +1,6 @@
- @@ -20,6 +19,19 @@
diff --git a/html-router/templates/chat/streaming_response.html b/html-router/templates/chat/streaming_response.html index c42bc5c..e76e21f 100644 --- a/html-router/templates/chat/streaming_response.html +++ b/html-router/templates/chat/streaming_response.html @@ -4,11 +4,11 @@
-
- +
@@ -16,34 +16,39 @@
diff --git a/html-router/templates/components/_sidebar_layout.html b/html-router/templates/components/_sidebar_layout.html index 614133f..e58eb77 100644 --- a/html-router/templates/components/_sidebar_layout.html +++ b/html-router/templates/components/_sidebar_layout.html @@ -1,60 +1,62 @@
- Recent Chats -
- {% if conversation_archive is defined and conversation_archive %} - {% for conversation in conversation_archive %} -
  • - {% if edit_conversation_id == conversation.id %} - -
    - -
    - - +
    +
    -
    +
    - + +
    diff --git a/html-router/templates/head_base.html b/html-router/templates/head_base.html index cc05ce3..38613d5 100644 --- a/html-router/templates/head_base.html +++ b/html-router/templates/head_base.html @@ -16,12 +16,20 @@ + + + @@ -38,49 +46,7 @@ {% block head %}{% endblock %} - {% block body %}{% endblock %} - - \ No newline at end of file diff --git a/html-router/templates/knowledge/relationship_table.html b/html-router/templates/knowledge/relationship_table.html index d5bfbe0..2b80082 100644 --- a/html-router/templates/knowledge/relationship_table.html +++ b/html-router/templates/knowledge/relationship_table.html @@ -62,7 +62,8 @@ + class="nb-input w-full new_relationship_input" value="{{ default_relationship_type }}" + hx-on:keydown="if(event.key==='Enter'){event.preventDefault();document.getElementById('save_relationship_button').click()}" />
  • - diff --git a/html-router/templates/modal_base.html b/html-router/templates/modal_base.html index 61e39e5..08aacd8 100644 --- a/html-router/templates/modal_base.html +++ b/html-router/templates/modal_base.html @@ -2,46 +2,32 @@ - diff --git a/html-router/templates/scratchpad/editor_modal.html b/html-router/templates/scratchpad/editor_modal.html index 5e6980f..bed48f7 100644 --- a/html-router/templates/scratchpad/editor_modal.html +++ b/html-router/templates/scratchpad/editor_modal.html @@ -2,7 +2,10 @@ {% block modal_class %}w-11/12 max-w-[90ch] max-h-[95%] overflow-y-auto{% endblock %} -{% block form_attributes %}{% endblock %} +{# title-form, auto-save-form, ingest-form, archive-form — override modal_base + wrapper blocks so these are not nested inside #modal_form. #} +{% block modal_form_open %}
    {% endblock %} +{% block modal_form_close %}
    {% endblock %} {% block modal_content %}

    @@ -34,7 +37,7 @@
    @@ -64,8 +67,8 @@ hx-on::after-request="if(event.detail.successful) document.getElementById('body_modal').close()" class="inline flex flex-col gap-3" id="ingest-form"> -
    Confirm ingest -
    diff --git a/html-router/templates/sidebar.html b/html-router/templates/sidebar.html index 6aad686..96f6354 100644 --- a/html-router/templates/sidebar.html +++ b/html-router/templates/sidebar.html @@ -22,7 +22,7 @@ hx-target="#modal" hx-swap="innerHTML">{% include "icons/send_icon.html" %} Add Content -
    + {% endblock %} {% block sidebar_bottom_actions %} diff --git a/html-router/tests/router_integration.rs b/html-router/tests/router_integration.rs index 6317f9b..25df4a6 100644 --- a/html-router/tests/router_integration.rs +++ b/html-router/tests/router_integration.rs @@ -107,6 +107,239 @@ async fn response_body(response: Response) -> String { String::from_utf8(body.to_vec()).expect("html body") } +/// Shared insta settings for HTML snapshots in this module. +/// +/// The in-memory DB is recreated per test, so ids in markup would otherwise churn. +/// Filters normalize those values; see `snapshot_*` tests below. +fn snapshot_settings() -> insta::Settings { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.add_filter( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + "[uuid]", + ); + settings.add_filter(r"[a-z_]+:[0-9a-z]{12,}", "[record-id]"); + settings +} + +const AUTHENTICATED_MAIN_OPEN: &str = r#"
    "#; + +/// Inner HTML of the scrollable page column from `body_base.html` (`{% block main %}`). +/// +/// Omits head, navbar shell, sidebar, and modal mount points so per-route snapshots +/// do not duplicate layout chrome (see `snapshot_authenticated_shell`). +fn extract_authenticated_main(html: &str) -> &str { + let start = html + .find(AUTHENTICATED_MAIN_OPEN) + .expect("authenticated page main column") + + AUTHENTICATED_MAIN_OPEN.len(); + let rest = &html[start..]; + let end = rest + .find("
    ") + .expect("authenticated page main column close"); + &rest[..end] +} + +async fn get_html(app: &Router, uri: &str, cookie: Option<&str>) -> String { + let mut builder = Request::builder().uri(uri); + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + let response = app + .clone() + .oneshot(builder.body(Body::empty()).expect("request")) + .await + .expect("response"); + response_body(response).await +} + +/// Fixed credentials for authenticated snapshot routes (dashboard, search, etc.). +async fn seeded_cookie(app: &Router, db: &SurrealDbClient) -> String { + User::create_new( + "snapshot_user@example.com".to_string(), + "snapshot_password".to_string(), + db, + "UTC".to_string(), + "system".to_string(), + ) + .await + .expect("snapshot user"); + sign_in(app, "snapshot_user@example.com", "snapshot_password").await +} + +/// Parses a scratchpad id from the list page HTML after `POST /scratchpad`. +async fn create_scratchpad_and_get_id(app: &Router, cookie: &str, title: &str) -> String { + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/scratchpad") + .header(header::COOKIE, cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("title={title}"))) + .expect("create request"), + ) + .await + .expect("create response"); + + let list = get_html(app, "/scratchpad", Some(cookie)).await; + let marker = "/scratchpad/"; + let start = list.find(marker).expect("scratchpad link present") + marker.len(); + list[start..start + list[start..].find('/').expect("id terminator")].to_string() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scratchpad_editor_modal_does_not_nest_forms() { + let (app, db) = build_test_app().await; + let cookie = seeded_cookie(&app, &db).await; + let id = create_scratchpad_and_get_id(&app, &cookie, "IngestPad").await; + + let modal = get_html(&app, &format!("/scratchpad/{id}/modal"), Some(&cookie)).await; + + // Scratchpad editor opts out of #modal_form (see editor_modal.html); nested + // elements are invalid HTML and browsers drop the inner forms. + assert!( + !modal.contains(r#"id="modal_form""#), + "editor modal should not wrap content in #modal_form" + ); + assert!( + modal.contains(&format!("/scratchpad/{id}/ingest")), + "ingest form action should be present" + ); + assert!( + modal.contains(r#"id="ingest-form""#), + "ingest form should be a real, addressable form" + ); + + // Ingest targets #main_section, so the response must be a partial, not a full page. + app.clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/scratchpad/{id}/auto-save")) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from("content=Some+content+to+ingest")) + .expect("save request"), + ) + .await + .expect("save response"); + + let ingest = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/scratchpad/{id}/ingest")) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .expect("ingest request"), + ) + .await + .expect("ingest response"); + assert_eq!(ingest.status(), StatusCode::OK); + let body = response_body(ingest).await; + assert!( + !body.trim_start().starts_with(" String { let response = app .clone() diff --git a/html-router/tests/snapshots/authenticated_shell.snap b/html-router/tests/snapshots/authenticated_shell.snap new file mode 100644 index 0000000..74b191c --- /dev/null +++ b/html-router/tests/snapshots/authenticated_shell.snap @@ -0,0 +1,391 @@ +--- +source: html-router/tests/router_integration.rs +expression: body +--- + + + + + + + Minne - Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    + + + +
    + +
    +
    +
    + + +
    + +

    Dashboard

    + + +
    +
    + + +
    +

    Overview

    +
    +
    +
    Total Documents
    +
    0
    +
    +0 this week
    +
    +
    +
    Text Chunks
    +
    0
    +
    +0 this week
    +
    +
    +
    Knowledge Entities
    +
    0
    +
    +0 this week
    +
    +
    +
    Conversations
    +
    0
    +
    +0 this week
    +
    +
    +
    + +
    +

    Recent content

    +
    + +
    + No content found. +
    + +
    +
    + + +
    +
    +

    Active Tasks

    +
    + + +
    +
    + +
    + + +
    +
    + +
    +
    + +
    + + +
    + + + +
    + +
    + +
    + + + + + + diff --git a/html-router/tests/snapshots/dashboard_main.snap b/html-router/tests/snapshots/dashboard_main.snap new file mode 100644 index 0000000..85a3424 --- /dev/null +++ b/html-router/tests/snapshots/dashboard_main.snap @@ -0,0 +1,91 @@ +--- +source: html-router/tests/router_integration.rs +expression: main +--- + + +
    +
    +
    + + +
    + +

    Dashboard

    + + +
    +
    + + +
    +

    Overview

    +
    +
    +
    Total Documents
    +
    0
    +
    +0 this week
    +
    +
    +
    Text Chunks
    +
    0
    +
    +0 this week
    +
    +
    +
    Knowledge Entities
    +
    0
    +
    +0 this week
    +
    +
    +
    Conversations
    +
    0
    +
    +0 this week
    +
    +
    +
    + +
    +

    Recent content

    +
    + +
    + No content found. +
    + +
    +
    + + +
    +
    +

    Active Tasks

    +
    + + +
    +
    + +
    + + +
    +
    + +
    diff --git a/html-router/tests/snapshots/new_entity_modal.snap b/html-router/tests/snapshots/new_entity_modal.snap new file mode 100644 index 0000000..1587860 --- /dev/null +++ b/html-router/tests/snapshots/new_entity_modal.snap @@ -0,0 +1,119 @@ +--- +source: html-router/tests/router_integration.rs +expression: body +--- + + + + diff --git a/html-router/tests/snapshots/not_found_main.snap b/html-router/tests/snapshots/not_found_main.snap new file mode 100644 index 0000000..4dc0d29 --- /dev/null +++ b/html-router/tests/snapshots/not_found_main.snap @@ -0,0 +1,17 @@ +--- +source: html-router/tests/router_integration.rs +expression: main +--- + + +
    +
    + +

    + 404 +

    +

    Page Not Found

    +

    The page you're looking for doesn't exist or was removed.

    + Go Home + +
    diff --git a/html-router/tests/snapshots/search_main.snap b/html-router/tests/snapshots/search_main.snap new file mode 100644 index 0000000..a61bd55 --- /dev/null +++ b/html-router/tests/snapshots/search_main.snap @@ -0,0 +1,46 @@ +--- +source: html-router/tests/router_integration.rs +expression: main +--- + + +
    +
    +
    +
    + +
    +

    Search

    +
    Find document snippets and knowledge entities
    +
    +
    + + + + +
    + +
    +
    + + +
    +

    Enter a term above to search your knowledge base.

    +

    Results will appear here.

    +
    + + +
    +
    + +
    diff --git a/html-router/tests/snapshots/signin_page.snap b/html-router/tests/snapshots/signin_page.snap new file mode 100644 index 0000000..5c0ed0f --- /dev/null +++ b/html-router/tests/snapshots/signin_page.snap @@ -0,0 +1,103 @@ +--- +source: html-router/tests/router_integration.rs +expression: body +--- + + + + + + + Minne - Sign in + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    +
    MINNE
    + Sign In +
    +
    + +
    + + + + +
    + +
    + + +
    + +
    +
    + +
    +
    + Don’t have an account? + Sign up +
    +
    +
    + +
    +
    + + + diff --git a/html-router/tests/snapshots/signup_page.snap b/html-router/tests/snapshots/signup_page.snap new file mode 100644 index 0000000..e1c216b --- /dev/null +++ b/html-router/tests/snapshots/signup_page.snap @@ -0,0 +1,108 @@ +--- +source: html-router/tests/router_integration.rs +expression: body +--- + + + + + + + Minne - Sign up + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    +
    MINNE
    + Sign Up +
    +
    + +
    + + + + +
    + +
    + +
    + +
    +
    + Already have an account? + Sign in +
    +
    +
    + + +
    +
    + + + diff --git a/html-router/tests/template_load.rs b/html-router/tests/template_load.rs new file mode 100644 index 0000000..bcb0570 --- /dev/null +++ b/html-router/tests/template_load.rs @@ -0,0 +1,60 @@ +//! Compile-time smoke test for every file under `templates/`. +//! +//! Loads each `.html` through minijinja with the same `path_loader` setup as the +//! app. Catches syntax and extends/include errors without rendering or hitting routes. +//! Complements insta snapshots in `router_integration.rs`, which test rendered HTML. + +#![allow(clippy::expect_used)] + +use std::fs; +use std::path::{Path, PathBuf}; + +use minijinja::{path_loader, Environment}; + +fn templates_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates") +} + +fn collect_html_templates(dir: &Path, root: &Path, out: &mut Vec) { + for entry in fs::read_dir(dir).expect("read template directory") { + let path = entry.expect("directory entry").path(); + if path.is_dir() { + collect_html_templates(&path, root, out); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("html") { + let rel = path.strip_prefix(root).expect("strip templates root"); + // minijinja template names use forward slashes regardless of OS. + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } +} + +#[test] +fn all_templates_compile() { + let root = templates_dir(); + + let mut env = Environment::new(); + env.set_loader(path_loader(&root)); + minijinja_contrib::add_to_environment(&mut env); + + let mut names = Vec::new(); + collect_html_templates(&root, &root, &mut names); + assert!( + !names.is_empty(), + "expected to discover template files under {}", + root.display() + ); + + let mut failures = Vec::new(); + for name in &names { + if let Err(error) = env.get_template(name) { + failures.push(format!("{name}: {error:#}")); + } + } + + assert!( + failures.is_empty(), + "{} template(s) failed to compile:\n{}", + failures.len(), + failures.join("\n") + ); +}