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") + ); +}