Skip to content
Open
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
79 changes: 53 additions & 26 deletions scripts/test-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,35 @@ assert_truthy() {
fi
}

# Poll `--eval` until it returns something truthy, or give up after ~4s.
# Defined up here rather than beside its first use so every suite can reach it.
poll_eval() {
local js="$1"
local tries=0
local out=""
while [ "$tries" -lt 40 ]; do
out=$("$ATTN" --eval "$js" 2>/dev/null || echo "")
case "$out" in
''|null|'""'|false|0) ;;
*) echo "$out"; return 0 ;;
esac
sleep 0.1
tries=$((tries + 1))
done
echo "$out"
}

# Wait for the document heading to actually BECOME `$1`.
#
# `--wait-for h1` cannot do this: an h1 from the previously-open document
# already satisfies it, so it returns instantly and whatever fixed sleep
# follows is racing the navigation. Losing that race made "Navigate to
# basic.md" read the previous file's heading (attn-537h).
wait_for_heading() {
local expected="$1"
poll_eval "(document.querySelector('h1')?.textContent || '').includes('$expected') ? 'yes' : null" >/dev/null
}

screenshot() {
local name="$1"
local path
Expand Down Expand Up @@ -233,10 +262,8 @@ echo "--- Navigate Between Files ---"
# Click basic.md in the sidebar
"$ATTN" --click 'text=basic.md'

# Wait for navigation to complete — h1 should contain "Project Status"
"$ATTN" --wait-for 'h1' --timeout 5000 >/dev/null 2>&1
# Give rendering a moment to settle after navigation
sleep 0.3
# Wait for the heading to become basic.md's, not merely for AN h1 to exist.
wait_for_heading "Project Status"
result=$("$ATTN" --query 'h1' | jq -r '.elements[0].text' 2>/dev/null || echo "")
assert_contains "Navigate to basic.md" "$result" "Project Status"
screenshot "05-navigate-basic"
Expand All @@ -255,15 +282,31 @@ sleep 0.3
# and the row text contains the filename — `text=` matches text content.
"$ATTN" --click 'text=child.md'

# Wait for navigation to complete
"$ATTN" --wait-for 'h1' --timeout 5000 >/dev/null 2>&1
sleep 0.3
wait_for_heading "Nested Document"
result=$("$ATTN" --query 'h1' | jq -r '.elements[0].text' 2>/dev/null || echo "")
assert_contains "Navigate to nested child.md" "$result" "Nested Document"

# Verify breadcrumb shows nested path
result=$("$ATTN" --query '[class*="breadcrumb"], nav[aria-label]' | jq -r '.elements[0].text // ""' 2>/dev/null || echo "")
assert_contains "Breadcrumb shows nested path" "$result" "child.md"
# The nested file is the one the app considers open.
#
# This replaces an assertion on a breadcrumb. The native app has no breadcrumb
# and has not had one for some time: `web/src/lib/PathBreadcrumb.svelte` is
# imported by nothing, and a live window reports zero matches for
# `[class*=breadcrumb]`, `nav[aria-label]` and `[data-slot*=breadcrumb]`. The
# old selector could only ever find nothing, so the case was asserting the
# absence of a control rather than any behaviour (attn-537h).
#
# What it was reaching for — "the app is showing the nested file" — is real and
# observable: the sidebar marks the open row `data-active="true"` and carries
# its full path in `data-path`.
# `--eval` hands back a JSON-encoded string, which escapes the separators —
# the path arrives as `...\/nested\/child.md`, so a bare `nested/child.md`
# never appears in it. Strip the escaping rather than assert on the escaped
# spelling, so the message still shows a readable path when this fails.
result=$(poll_eval "(() => {
const row = document.querySelector('[data-path][data-active=\"true\"]');
return row ? row.getAttribute('data-path') : null;
})()" | tr -d '\\')
assert_contains "Nested file is the active sidebar row" "$result" "nested/child.md"
screenshot "06-nested-file"

# ===================================================================
Expand All @@ -276,22 +319,6 @@ echo "=== Test Suite 3: Relative Images (images.md) ==="
# Poll a synchronous eval until it stops returning `null`/empty. `--eval` hands
# back whatever the expression evaluates to, JSON-encoded, and does NOT await a
# Promise — so waiting has to happen out here, not in the page.
poll_eval() {
local js="$1"
local tries=0
local out=""
while [ "$tries" -lt 40 ]; do
out=$("$ATTN" --eval "$js" 2>/dev/null || echo "")
case "$out" in
''|null|'""'|false|0) ;;
*) echo "$out"; return 0 ;;
esac
sleep 0.1
tries=$((tries + 1))
done
echo "$out"
}

start_daemon "$FIXTURES/images.md"

# Single-file mode, so directory ordering in tests/fixtures/ is irrelevant here.
Expand Down
31 changes: 30 additions & 1 deletion src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,35 @@ function __resolve(sel) {
}
return Array.from(document.querySelectorAll(sel));
}

// Which of several `text=` matches should a CLICK land on.
//
// A text match is satisfied by every element whose trimmed text equals the
// target, which for one sidebar row is the <li>, the <button> inside it, and
// the <span> inside that. Taking the first in document order takes the <li> —
// an element with no click handler — so the click reported success and nothing
// happened. That is how "Navigate to basic.md" failed while its sibling case
// passed vacuously: the document had not moved for either, and the sibling
// happened to already be open (attn-537h).
//
// Prefer the innermost INTERACTIVE match, since that is the thing a person
// would have clicked. Fall back to the innermost match of any kind, whose
// click still bubbles to whatever handler is above it.
function __best(els) {
if (els.length < 2) return els[0];
var interactive = els.filter(function (el) {
return el.matches('button, a, [role=button], input, select, textarea, label, [data-sidebar="menu-button"]');
});
var pool = interactive.length > 0 ? interactive : els;
// Innermost = the one containing no other candidate.
for (var i = 0; i < pool.length; i++) {
var containsAnother = pool.some(function (other) {
return other !== pool[i] && pool[i].contains(other);
});
if (!containsAnother) return pool[i];
}
return pool[0];
}
"#;

match action {
Expand All @@ -1371,7 +1400,7 @@ function __resolve(sel) {
{resolve_fn}
var els = __resolve({sel_json});
if (els.length === 0) return JSON.stringify({{status:'not_found',selector:{sel_json}}});
els[0].click();
__best(els).click();
return JSON.stringify({{status:'ok'}});
}})()"#,
)
Expand Down
5 changes: 3 additions & 2 deletions src/review/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,19 @@ const IMAGE_EXTENSIONS: &[(&str, &str)] = &[
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
("avif", "image/avif"),
("bmp", "image/bmp"),
("ico", "image/x-icon"),
("svg", "image/svg+xml"),
];

/// Largest single asset that may be published. Generous for a screenshot or a
/// diagram, small enough that one pathological file cannot dominate a share.
pub const MAX_ASSET_BYTES: u64 = 8 * 1024 * 1024;
pub const MAX_ASSET_BYTES: u64 = 3 * 1024 * 1024;

/// Ceiling on everything one document contributes. A snapshot is base64url'd
/// into an encrypted envelope, so the wire cost is ~4/3 of this.
pub const MAX_TOTAL_BYTES: u64 = 32 * 1024 * 1024;
pub const MAX_TOTAL_BYTES: u64 = 16 * 1024 * 1024;

/// Ceiling on how many assets one document contributes, so a generated file
/// with a thousand thumbnails cannot stall a share.
Expand Down
107 changes: 92 additions & 15 deletions src/review/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4703,27 +4703,46 @@ fn selected_share_wire_path(
let Some(record) = all.get(room_id.as_str()) else {
return Ok(None);
};
if record.selected_paths.is_empty() {
return Ok(None);
}
let canonical_root = std::path::Path::new(&record.path)
.canonicalize()
.map_err(|error| {
BootstrapError::Store(format!(
"canonicalize selected share root {}: {error}",
record.path
))
})?;
// A share with no curated file list still has a root, and every file it
// publishes still needs a portable name (attn-x2zq).
//
// Returning None here meant `attn review share <path>` — and the legacy
// share(path) wrapper, which passes an empty selection — produced a share
// whose images could not travel at all: publish_asset_snapshot fails
// without a wire path, so the bytes were scanned, approved, and then
// dropped with only a per-image log line. It was invisible in testing
// because the native Share dialog always sends a selection.
//
// The root is the shared directory, or a shared file's own parent. Files
// outside it are still refused, by normalized_relative_share_path below.
let record_root = std::path::Path::new(&record.path);
let root_for_wire = if record.is_dir {
record_root.to_path_buf()
} else {
record_root
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| record_root.to_path_buf())
};
let canonical_root = root_for_wire.canonicalize().map_err(|error| {
BootstrapError::Store(format!(
"canonicalize selected share root {}: {error}",
root_for_wire.display()
))
})?;
let canonical_path = path.canonicalize().map_err(|error| {
BootstrapError::Store(format!(
"canonicalize shared file {}: {error}",
path.display()
))
})?;
if !record
.selected_paths
.iter()
.any(|selected| std::path::Path::new(selected) == canonical_path)
// With a curated list, membership of it is what authorises a wire path.
// Without one, containment in the share root is.
if !record.selected_paths.is_empty()
&& !record
.selected_paths
.iter()
.any(|selected| std::path::Path::new(selected) == canonical_path)
{
return Ok(None);
}
Expand Down Expand Up @@ -5727,6 +5746,64 @@ mod tests {
}
}

#[test]
fn a_share_without_a_curated_list_still_mints_wire_paths() {
// attn-x2zq. Returning None here meant `attn review share <path>` and
// the legacy share(path) wrapper published a share whose images could
// not travel: publish_asset_snapshot needs a portable name, so the
// bytes were scanned, approved, and then dropped. Invisible in testing
// because the native Share dialog always sends a selection.
let root = TempDir::new().expect("tmp");
let store_root = root.path().join("store");
std::fs::create_dir_all(&store_root).expect("store dir");
let docs = root.path().join("docs");
std::fs::create_dir_all(docs.join("nested")).expect("docs dir");
std::fs::write(docs.join("notes.md"), b"# hi").expect("doc");
std::fs::write(docs.join("chart.svg"), b"<svg/>").expect("asset");
std::fs::write(docs.join("nested/diagram.png"), b"\x89PNG").expect("nested asset");

let room: RoomId =
serde_json::from_value(serde_json::Value::String("room-no-selection".into())).unwrap();

// A FOLDER share: the root is the folder itself.
record_local_share(&store_root, &room, &docs, true).expect("record dir share");
assert_eq!(
selected_share_wire_path(&store_root, &room, &docs.join("chart.svg"))
.expect("wire path")
.as_deref(),
Some("chart.svg"),
"an asset beside the document gets a portable name"
);
assert_eq!(
selected_share_wire_path(&store_root, &room, &docs.join("nested/diagram.png"))
.expect("wire path")
.as_deref(),
Some("nested/diagram.png"),
"and keeps its subdirectory"
);

// A SINGLE-FILE share: the root is that file's own parent, so its
// siblings are still nameable.
let file_room: RoomId =
serde_json::from_value(serde_json::Value::String("room-single-file".into())).unwrap();
record_local_share(&store_root, &file_room, &docs.join("notes.md"), false)
.expect("record file share");
assert_eq!(
selected_share_wire_path(&store_root, &file_room, &docs.join("chart.svg"))
.expect("wire path")
.as_deref(),
Some("chart.svg"),
"a single-file share roots at the file's directory"
);

// Containment still holds: nothing above the root is nameable.
std::fs::write(root.path().join("outside.svg"), b"<svg/>").expect("outside");
assert!(
selected_share_wire_path(&store_root, &room, &root.path().join("outside.svg")).is_err(),
"a file outside the share root has no wire path"
);
}

#[test]
fn v3_fragment_without_an_owner_key_still_parses() {
// Backward skew: an invite minted before attn-lb7p has no owner key.
Expand Down
Loading
Loading