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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ together).

### Added

- **A row that needs two columns to name it could not be named.** The row
target identifies by content instead of position, which is the whole
reason it exists — but it took one anchor, and one column is often not
unique. A table with two people called John and two called Doe has no
single anchor that finds John Doe's row. `containing "Doe"` failed with
`matches 2 rows`, correctly and unhelpfully, and the only thing left to
write was `tr:nth-child(2)`: the positional selector this target was built
to remove, reintroduced by hand.

```yaml
- Click the "Edit" in the item containing "John" and "Doe"
- assert: the "Email" column of the row containing "John" and "Doe" shows john@example.com
```

Every anchor must be in the SAME row, so the conjunction narrows rather
than widens. Anchors that sit in different rows match nothing rather than
picking one of them, and a conjunction that is still ambiguous gives the
same `matches N rows` error a single anchor does — the diagnostic is not
weakened by having more ways to be specific.

The quotes delimit and `and` is a separator, the same rule the multi-option
`Select` above uses, for the same reason: an anchor is arbitrary page text.

This is not the selector growth the charter declines. That clause is about
adopting another framework's idioms; this is flowproof's own
identity-not-position principle reaching a table where one column does not
identify anything.

- **Selecting four options selected one, and said nothing.** `Select` commits
through the control's value setter, which is correct and is also a
*replacement*. So the obvious spelling — four `Select` steps at one
Expand Down
32 changes: 26 additions & 6 deletions crates/flowproof-adapters/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const CELL_HINTS: &str = r#"function(){
return JSON.stringify({ field: fieldOf(c), id: id });
}"#;

const CELL_RESOLVER: &str = r#"function(COL, ANCHOR, COLFIELD, ROWID){
const CELL_RESOLVER: &str = r#"function(COL, ANCHOR, COLFIELD, ROWID, ALSO){
document.querySelectorAll('[data-flowproof-cell]').forEach(function(e){
e.removeAttribute('data-flowproof-cell');
});
Expand Down Expand Up @@ -134,7 +134,17 @@ const CELL_RESOLVER: &str = r#"function(COL, ANCHOR, COLFIELD, ROWID){
});
if (!rows.length) continue;
var idRow = ROWID ? rows.find(function(r){ return idOf(r) === ROWID; }) : null;
var anchorRows = rows.filter(function(r){ return txt(r).indexOf(ANCHOR) !== -1; });
// EVERY anchor must be in the SAME row. One column is often not
// unique - two people called John, two called Doe - and requiring the
// conjunction is how a row is named without falling back to position.
var anchorRows = rows.filter(function(r){
var t = txt(r);
if (t.indexOf(ANCHOR) === -1) return false;
for (var a = 0; a < (ALSO || []).length; a++) {
if (t.indexOf(ALSO[a]) === -1) return false;
}
return true;
});
var chosen = null;
if (idRow){
if (txt(idRow).indexOf(ANCHOR) !== -1) chosen = idRow;
Expand Down Expand Up @@ -168,7 +178,7 @@ const ITEM_CONTAINERS: &str = "li, [role=listitem], [role=row], [role=option], [
/// discarded, so the INNERMOST wins. Exactly one must remain. Returns a
/// status: `ok`, `no_match`, `anchor_without_container`, `ambiguous:<n>`,
/// or `bad_container`.
const SCOPE_RESOLVER: &str = r#"function(CONTAINER, ANCHOR, CONTAINERID, ITEMS){
const SCOPE_RESOLVER: &str = r#"function(CONTAINER, ANCHOR, CONTAINERID, ITEMS, ALSO){
document.querySelectorAll('[data-flowproof-scope]').forEach(function(e){
e.removeAttribute('data-flowproof-scope');
});
Expand All @@ -186,7 +196,15 @@ const SCOPE_RESOLVER: &str = r#"function(CONTAINER, ANCHOR, CONTAINERID, ITEMS){
var candidates;
try { candidates = Array.prototype.slice.call(document.querySelectorAll(selector)); }
catch (e) { return 'bad_container'; }
var matching = candidates.filter(function(c){ return txt(c).indexOf(ANCHOR) !== -1; });
// Every anchor in the SAME container - see the cell resolver.
var matching = candidates.filter(function(c){
var t = txt(c);
if (t.indexOf(ANCHOR) === -1) return false;
for (var a = 0; a < (ALSO || []).length; a++) {
if (t.indexOf(ALSO[a]) === -1) return false;
}
return true;
});
// Innermost wins: drop any survivor that contains another survivor.
var inner = matching.filter(function(c){
return !matching.some(function(o){ return o !== c && c !== o && c.contains(o); });
Expand Down Expand Up @@ -739,11 +757,12 @@ impl WebAppDriver {
.unwrap_or_else(|| "null".into())
};
format!(
"({CELL_RESOLVER})({col},{anchor},{field},{rowid})",
"({CELL_RESOLVER})({col},{anchor},{field},{rowid},{also})",
col = serde_json::Value::from(cell.column.as_str()),
anchor = serde_json::Value::from(cell.anchor.as_str()),
field = opt(&cell.column_field),
rowid = opt(&cell.row_id),
also = serde_json::Value::from(cell.also.clone()),
)
}

Expand Down Expand Up @@ -808,11 +827,12 @@ impl WebAppDriver {
.unwrap_or_else(|| "null".into())
};
format!(
"({SCOPE_RESOLVER})({container},{anchor},{id},{items})",
"({SCOPE_RESOLVER})({container},{anchor},{id},{items},{also})",
container = serde_json::Value::from(scope.container.as_str()),
anchor = serde_json::Value::from(scope.anchor.as_str()),
id = opt(&scope.container_id),
items = serde_json::Value::from(ITEM_CONTAINERS),
also = serde_json::Value::from(scope.also.clone()),
)
}

Expand Down
26 changes: 24 additions & 2 deletions crates/flowproof-agent/src/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,20 @@ fn selectors_for(app: &str, target: &Target, label: Option<&str>) -> Vec<Selecto
// by header text + row anchor. The `kind: "cell"` payload is
// self-describing; the adapter enriches it with column_field/row_id
// hints at record time, and a text-only payload stays valid.
Target::Cell { column, anchor } => {
Target::Cell {
column,
anchor,
also,
} => {
let mut payload = serde_json::Map::new();
payload.insert("kind".into(), "cell".into());
payload.insert("column_text".into(), column.as_str().into());
payload.insert("row_anchor".into(), anchor.as_str().into());
// A new key beside the primary anchor, so a reader that only
// knows one anchor still reads the payload it expects.
if !also.is_empty() {
payload.insert("row_anchor_also".into(), serde_json::json!(also));
}
vec![Selector {
tier: SelectorTier::Structural,
provenance: flowproof_trace::format::Adapter::Web,
Expand All @@ -241,11 +250,15 @@ fn selectors_for(app: &str, target: &Target, label: Option<&str>) -> Vec<Selecto
Target::Scoped {
container,
anchor,
also,
inner,
} => {
let mut payload = serde_json::Map::new();
payload.insert("kind".into(), "scoped".into());
payload.insert("container".into(), container.as_str().into());
if !also.is_empty() {
payload.insert("anchor_also".into(), serde_json::json!(also));
}
payload.insert("container_anchor".into(), anchor.as_str().into());
match inner.as_ref() {
Target::Css(css) => payload.insert("inner_css".into(), css.as_str().into()),
Expand Down Expand Up @@ -903,21 +916,28 @@ fn target_selector(target: &Target) -> Option<UiaSelector> {
..UiaSelector::default()
})
}
Target::Cell { column, anchor } => Some(UiaSelector {
Target::Cell {
column,
anchor,
also,
} => Some(UiaSelector {
cell: Some(flowproof_driver::CellQuery {
column: column.clone(),
anchor: anchor.clone(),
also: also.clone(),
..Default::default()
}),
..UiaSelector::default()
}),
Target::Scoped {
container,
anchor,
also,
inner,
} => Some(UiaSelector {
scope: Some(flowproof_driver::ScopeQuery {
container: container.clone(),
also: also.clone(),
anchor: anchor.clone(),
inner_css: match inner.as_ref() {
Target::Css(css) => Some(css.clone()),
Expand Down Expand Up @@ -3679,6 +3699,7 @@ steps:
Target::Scoped {
container: "item".into(),
anchor: "Invoice 4711".into(),
also: Vec::new(),
inner: Box::new(inner),
}
}
Expand Down Expand Up @@ -3765,6 +3786,7 @@ steps:
target: Target::Cell {
column: "Actions".into(),
anchor: "Grace Hopper".into(),
also: Vec::new(),
},
label: "Actions".into(),
dialog: None,
Expand Down
Loading
Loading