use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::adapters::chrome::testing::{self, eval, until};
use crate::drive::http;
use devpage_core::comments::*;
use devpage_core::document::Document;
use devpage_core::folder::ServedFolder;
async fn open(root: &Path, entry: &Path, path: &str) -> (testing::Browser, chromiumoxide::Page) {
let opened = open_off(root, entry, path).await;
eval::<bool>(
&opened.1,
"(document.querySelector('.comment-mode').click(), true)",
)
.await;
opened
}
async fn open_off(
root: &Path,
entry: &Path,
path: &str,
) -> (testing::Browser, chromiumoxide::Page) {
let document = Arc::new(Document::load(entry, &ServedFolder::new(root).unwrap()).unwrap());
let changes = crate::adapters::changes::Changes::default();
let app = http::App::testing(document, changes.clone())
.with_comments_on_disk(devpage_core::comments::Identity::Local("Ana".into()));
let watcher = app.watch().unwrap();
let address = testing::serve(http::router(app)).await;
tokio::spawn(async move {
let _watcher = watcher;
std::future::pending::<()>().await;
});
let browser = testing::browser_on(1400, 900).await;
let page = testing::open(&browser, &format!("{address}{path}")).await;
(browser, page)
}
fn folder(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("devpage-browser-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
root
}
#[tokio::test]
#[ignore]
async fn commenting_is_off_until_comment_mode_is_on_and_stays_on_after_a_reload() {
let root = folder("mode");
std::fs::write(root.join("a.md"), "# A\n\nA paragraph to comment on.\n").unwrap();
let (mut browser, page) = open_off(&root, &root.join("a.md"), "/a.md").await;
let hover = "(document.querySelector('article p').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })), true)";
let pressed = "document.querySelector('.comment-mode').getAttribute('aria-pressed')";
eval::<bool>(&page, hover).await;
assert!(!eval::<bool>(&page, "!!document.querySelector('.comment-button')").await);
assert_eq!(eval::<String>(&page, pressed).await, "false");
eval::<bool>(
&page,
"(document.querySelector('.comment-mode').click(), true)",
)
.await;
assert_eq!(eval::<String>(&page, pressed).await, "true");
eval::<bool>(&page, "(document.querySelector('article h1').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })), true)").await;
eval::<bool>(&page, hover).await;
until(&page, "!!document.querySelector('.comment-button')").await;
eval::<bool>(
&page,
"(document.querySelector('.comment-button').click(), true)",
)
.await;
until(
&page,
"!!document.querySelector('#devpage-comments .composer textarea')",
)
.await;
eval::<bool>(
&page,
"(() => { const box = document.querySelector('#devpage-comments .composer textarea');
box.value = 'Is this the right paragraph?';
box.closest('form').requestSubmit(); return true; })()",
)
.await;
until(&page, "!!document.querySelector('mark.comment')").await;
page.reload().await.unwrap();
testing::loaded(&page).await;
assert_eq!(eval::<String>(&page, pressed).await, "true");
eval::<bool>(
&page,
"(document.querySelector('.comment-mode').click(), true)",
)
.await;
eval::<bool>(&page, hover).await;
assert!(!eval::<bool>(&page, "!!document.querySelector('.comment-button')").await);
until(
&page,
"!!document.querySelector('#devpage-comments .thread')",
)
.await;
browser.close().await.ok();
}
#[tokio::test]
#[ignore]
async fn selected_text_becomes_a_thread_in_the_margin_and_in_the_store() {
let root = folder("select");
std::fs::write(
root.join("a.md"),
"# A\n\nThe client retries every write until the server acknowledges it.\n",
)
.unwrap();
let (mut browser, page) = open(&root, &root.join("a.md"), "/a.md").await;
eval::<bool>(
&page,
"(() => { const p = document.querySelector('article p'); const t = p.firstChild;
const at = t.data.indexOf('retries every write');
const r = document.createRange(); r.setStart(t, at); r.setEnd(t, at + 19);
getSelection().removeAllRanges(); getSelection().addRange(r);
p.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); return true; })()",
)
.await;
until(&page, "!!document.querySelector('.comment-button')").await;
eval::<bool>(
&page,
"document.querySelector('.comment-button').click(), true",
)
.await;
until(
&page,
"!!document.querySelector('#devpage-comments .composer textarea')",
)
.await;
eval::<bool>(
&page,
"(() => { const box = document.querySelector('#devpage-comments .composer textarea');
box.value = 'Every write, or only the outbox?';
box.closest('form').requestSubmit(); return true; })()",
)
.await;
until(
&page,
"document.querySelector('mark.comment')?.textContent === 'retries every write'",
)
.await;
let layout: String = eval(
&page,
"innerWidth + ' ' + document.getElementById('devpage-comments').className
+ ' ' + !!document.querySelector('#devpage-comments .composer')",
)
.await;
assert_eq!(
layout, "1400 comment-ui beside false",
"the thread sits beside the text and the composer closed"
);
let threads = yaml::Yaml::new(&ServedFolder::new(&root).unwrap())
.list("/a.md")
.unwrap();
assert_eq!(threads.len(), 1);
let thread = &threads[0].thread;
assert_eq!(thread.anchor.quote, "retries every write");
assert_eq!(
thread.anchor.prefix, "A The client ",
"up to 32 characters of the text before, headings included"
);
assert_eq!(thread.anchor.suffix, " until the server acknowledges i");
assert_eq!(thread.thread[0].author, "Ana");
assert_eq!(thread.thread[0].text, "Every write, or only the outbox?");
std::fs::write(root.join("a.md"), "# A\n\nThe client gives up.\n").unwrap();
until(
&page,
"(document.getElementById('devpage-comments')?.textContent ?? '').includes('Detached comments')
&& !document.querySelector('mark.comment')"
).await;
browser.close().await.ok();
}
#[tokio::test]
#[ignore]
async fn the_paragraph_comment_icon_stays_inside_narrow_windows() {
use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
let root = folder("narrow");
std::fs::write(
root.join("a.md"),
"# A\n\nA paragraph long enough to reach the right edge of a narrow window, and then some more words.\n\n| a | b | c | d | e |\n| --- | --- | --- | --- | --- |\n| a_rather_long_cell_value | another_rather_long_cell | a_third_long_cell_value | yet_another_long_cell | the_last_long_cell_value |\n",
)
.unwrap();
let (mut browser, page) = open(&root, &root.join("a.md"), "/a.md").await;
for width in [390, 820] {
page.execute(SetDeviceMetricsOverrideParams::new(width, 800, 1.0, false))
.await
.unwrap();
testing::next_frames(&page).await;
eval::<bool>(
&page,
"(document.querySelector('article h1').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })),
document.querySelector('.comment-button')?.remove(),
document.querySelector('article p').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })), true)",
)
.await;
until(&page, "!!document.querySelector('.comment-button')").await;
let fits: String = eval(
&page,
"(() => { const b = document.querySelector('.comment-button').getBoundingClientRect();
const w = document.documentElement.clientWidth;
return [b.left >= 0, b.right <= w, document.documentElement.scrollWidth <= w].join(' '); })()",
)
.await;
assert_eq!(
fits, "true true true",
"at {width}px: icon left, icon right, no page scroll"
);
}
browser.close().await.ok();
}
#[tokio::test]
#[ignore]
async fn a_paragraph_comment_on_a_nested_list_item_anchors_again_after_a_reload() {
let root = folder("paragraph");
std::fs::write(
root.join("a.md"),
"# A\n\n- Writes\n - retried until acknowledged\n - dropped after a day\n- Reads\n",
)
.unwrap();
let (mut browser, page) = open(&root, &root.join("a.md"), "/a.md").await;
eval::<bool>(
&page,
"(document.querySelector('article li').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })), true)",
)
.await;
until(&page, "!!document.querySelector('.comment-button')").await;
eval::<bool>(
&page,
"document.querySelector('.comment-button').click(), true",
)
.await;
let quote: String = eval(
&page,
"document.querySelector('#devpage-comments .composer blockquote').textContent",
)
.await;
assert_eq!(
quote,
"Writes retried until acknowledged dropped after a day"
);
eval::<bool>(
&page,
"(() => { const box = document.querySelector('#devpage-comments .composer textarea');
box.value = 'Why a day?'; box.closest('form').requestSubmit(); return true; })()",
)
.await;
until(
&page,
"document.querySelectorAll('mark.comment').length > 0",
)
.await;
page.reload().await.unwrap();
testing::loaded(&page).await;
until(
&page,
"[...document.querySelectorAll('mark.comment')].map(m => m.textContent).join('|')
.replace(/\\s+/g, ' ') .includes('retried until acknowledged')
&& !(document.getElementById('devpage-comments')?.textContent ?? '').includes('Detached')"
).await;
browser.close().await.ok();
}
#[tokio::test]
#[ignore]
async fn on_a_deck_c_toggles_the_comments_panel() {
let root = folder("deck");
std::fs::write(
root.join("devpage.yml"),
"kind: slides\ntitle: Talk\nslides: [one.md, two.md]\n",
)
.unwrap();
std::fs::write(root.join("one.md"), "# One\n\nThe opening line.\n").unwrap();
std::fs::write(root.join("two.md"), "# Two\n").unwrap();
let anchor = Anchor {
quote: "opening line".into(),
prefix: "The ".into(),
suffix: ".".into(),
};
let message = Message {
author: "Bo".into(),
at: "2026-10-25T18:15:02Z".into(),
text: "Sharper?".into(),
};
yaml::Yaml::new(&ServedFolder::new(&root).unwrap())
.create("/one.md", anchor, message)
.unwrap();
let (mut browser, page) = open(&root, &root.join("devpage.yml"), "/devpage.yml").await;
let shown = "getComputedStyle(document.getElementById('devpage-comments')).display !== 'none'";
until(&page, "!!document.querySelector('mark.comment')").await;
assert!(!eval::<bool>(&page, shown).await, "hidden until asked for");
eval::<bool>(
&page,
"(document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })), true)",
)
.await;
until(&page, shown).await;
let text: String = eval(
&page,
"document.getElementById('devpage-comments').innerText",
)
.await;
assert!(text.contains("Sharper?"), "{text}");
eval::<bool>(
&page,
"(document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })), true)",
)
.await;
until(&page, &format!("!({shown})")).await;
browser.close().await.ok();
}
#[tokio::test]
#[ignore]
async fn a_live_update_with_the_comments_shown_swaps_the_page_in_place() {
let root = folder("shown");
std::fs::write(root.join("a.md"), "# A\n\nThe first draft.\n").unwrap();
let (mut browser, page) = open(&root, &root.join("a.md"), "/a.md").await;
eval::<bool>(
&page,
"(document.body.classList.add('show-comments'), window.notReloaded = true, true)",
)
.await;
std::fs::write(root.join("a.md"), "# A\n\nThe second draft.\n").unwrap();
until(
&page,
"document.querySelector('article p')?.textContent === 'The second draft.'",
)
.await;
assert!(
eval::<bool>(&page, "window.notReloaded === true").await,
"the page was swapped in place, not reloaded"
);
assert!(
eval::<bool>(&page, "document.body.classList.contains('show-comments')").await,
"the comments stay shown"
);
browser.close().await.ok();
}