Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/botloftd/src/browser/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,9 @@
const walk = (node, parentCursor, depth) => {
if (++nodes > MAX_NODES || depth > MAX_DEPTH) return;
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue.replace(/\s+/g, " ");
if (text.trim()) out.push(text);
// A text node of spaces alone still parts words: pages that put
// each letter in its own element keep their spaces that way.
out.push(node.nodeValue.replace(/\s+/g, " "));
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
Expand Down
13 changes: 13 additions & 0 deletions crates/botloftd/tests/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ async fn a_bot_fills_a_form_after_the_owner_allows_the_site() {
assert!(closed.expect_err("closed").contains("not open"));
}

#[tokio::test(flavor = "multi_thread")]
async fn words_made_of_one_element_per_letter_keep_their_spaces() {
let Some(mut b) = setup().await else { return };
let opening = call(
&b.mcp,
"browser_open",
json!({ "url": format!("{}/letters", b.site) }),
);
answer_site(&mut b.app, true, None).await;
let page = opening.await.expect("task").expect("page");
assert!(page.contains("Two words"), "{page}");
}

#[tokio::test(flavor = "multi_thread")]
async fn a_site_the_owner_denies_stays_closed() {
let Some(mut b) = setup().await else { return };
Expand Down
11 changes: 9 additions & 2 deletions crates/botloftd/tests/common/site.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! A small web site on 127.0.0.1 for the browser tests: a form, the page
//! it leads to, a link that opens a new tab, a button that shows a dialog,
//! a sign-in page with its fields at fixed points, for the owner's hands,
//! a page that says how big its window is, one that counts its loads and
//! one that counts the times it went out of sight.
//! a page that says how big its window is, one that counts its loads, one
//! that counts the times it went out of sight and one with each letter in
//! its own element.

use std::collections::HashMap;
use std::net::SocketAddr;
Expand All @@ -25,6 +26,11 @@ const POPUP: &str = "<!doctype html><title>Links</title>\
<a href=\"/done?name=Tab&size=Small\" target=\"_blank\">Open in a new tab</a>\
<button onclick=\"alert('Hi there')\">Warn me</button>";

/// Each letter in a span, spaces too, as example.com's own script does.
const LETTERS: &str = "<!doctype html><title>Letters</title><p><span>T</span><span>w</span>\
<span>o</span><span> </span><span>w</span><span>o</span><span>r</span><span>d</span>\
<span>s</span></p>";

/// The fields' middles: user (200, 115), password (200, 215).
const LOGIN: &str = "<!doctype html><title>Sign in</title><style>body{margin:0}\
input{position:absolute;left:100px;width:200px;height:30px;margin:0;padding:0;\
Expand Down Expand Up @@ -86,6 +92,7 @@ pub async fn serve() -> SocketAddr {
.route("/login", get(|| async { Html(LOGIN) }))
.route("/size", get(|| async { Html(SIZE) }))
.route("/sight", get(|| async { Html(SIGHT) }))
.route("/letters", get(|| async { Html(LETTERS) }))
.route("/account", post(account));
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
Expand Down
1 change: 1 addition & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
```

- Um script injetado (`Runtime.evaluate`) percorre o documento em ordem de leitura: texto visível, títulos com `#`, itens de lista com `-`, quebras de bloco, e cada controle entre colchetes com uma `ref` (`e<n>`), o papel (`link`, `button`, `textbox`, `checkbox`, `radio`, `select`, `clickable`...), o nome acessível (`aria-label`, `<label>`, texto, `placeholder`, `title`, `alt`) e o estado (valor, marcado, desabilitado). Senhas aparecem só como `(password)`.
- Um nó de texto só com espaços vira um espaço, e espaços seguidos viram um. Assim uma página que põe cada letra num elemento próprio, espaços incluídos (o script do example.com faz isso com o texto em inglês), não chega ao bot com as palavras grudadas.
- Entra o que está visível na página inteira, não só na tela: nada com `display: none`, `visibility: hidden`, `aria-hidden` ou tamanho zero. `iframe` do mesmo site e shadow DOM aberto entram; de outro site, só `[frame]`.
- Elementos clicáveis sem papel (um `div` com `cursor: pointer` que não herda o cursor do pai) viram `clickable`.
- A mesma ref aponta sempre para o mesmo elemento enquanto ele existir, também entre leituras.
Expand Down
Loading