← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.25+0.9.26
Cargo.lock+1−1
@@ -122,7 +122,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.25"+version = "0.9.26" dependencies = [  "miniz_oxide",  "serde",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.25"+version = "0.9.26" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+10−1
@@ -492,7 +492,16 @@ not.  ## Roadmap -- v0.9.25 (this): **the page is prepared; the password stays with the user.**+- v0.9.26 (this): **page 2, read right.** Measured on the live window, which+  was on page 2 with the name filled. 0.9.25 called it page 1 because the+  user-name box is there too. It tried to set that box, which is prefilled and+  read-only, and blamed the refusal on a cookie banner that was really a+  "Manage cookies" link. It toasted Drew to answer that banner and never asked+  for the password. Now a page with the password box is page 2, a read-only+  name counts as prefilled, and only accept/reject buttons make a banner. A+  failed set is never blamed on a banner, and "Remember me" is toggled in the+  background and reported from its read-back. 561 tests.+- v0.9.25: **the page is prepared; the password stays with the user.**   Measured on the live window: ab refuses `pup_credential_reveal` to a bridge   ("requires a VERIFIED local transport … origin "bridge""). That is ab's   policy, and the bridge does not work around it. `swx_sign_in` now fills the
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.25",+  "version": "0.9.26",   "description": "Bridge for SOLIDWORKS desktop CAD — create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",
docs/LAUNCH-RESILIENCE.md+2
@@ -1373,6 +1373,8 @@ false, waitingOn: null`. The bridge saw nothing, while a sign-in sat on screen.  | U7 — ab's policy (0.9.25) | with the 0.9.24 envelope fix, `pup_credentials` answered (the vault holds iam.3ds.com) and `pup_credential_reveal` was refused: "pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin \"bridge\"". ab POLICY: the secret goes only to a local desktop assistant. Filed adom/adom-bridge #237 (`desktop_credential_fill`, or a verified-local origin for hosted bridges) | The page is PREPARED BEFORE THE REVEAL: the user name (from `pup_credentials`' host list, no secret, no toast) is filled and Continue pressed, with the same idle/foreground rules; the password page is recognised; only then is the reveal asked, once. A verified/local/origin refusal is `sign_in_vault_policy` (not `sign_in_vault_unavailable`) with `abSaid` (ab's exact message), `pagePrepared {page, userNameFilled}`, `waysOut` (the user types the password; #237) and a toast telling the user. Never retried, never worked around. `swx_status` / Visualize `how` and `parkedBehind` texts say the same | +| U8 — page 2, awake (0.9.26) | hosted 0.9.25 on window 857684, on page 2 with the name filled: the tree was AWAKE (37 nodes, `located.source:"tree"`, 0 wakes; the earlier find_control probing had woken it; 14 nodes is the COLD state). Page 2 holds Edit `username` (prefilled), Edit `password`, a real on-screen CheckBox "Remember me " (settable, toggle pattern), Log in, Back, links incl. "Manage cookies", RootWebArea "Login \| 3DEXPERIENCE ID \| Dassault Systèmes"; no banner. 0.9.25 called it page 1, tried to set the name ("UIA SetValue failed: Cannot call SetValue on read only value"), blamed a cookie banner (from the "Manage cookies" LINK), toasted the user to answer it, and never asked the reveal | A page with the password edit is page 2 (or a one-page form), whatever else it holds. On it, a name that holds text is left alone, and a read-only refusal means `nameField:"prefilled"` (`userNameFilled:true`); no name failure blocks the password step. `cookieBanner` = an accept/reject/preferences BUTTON only; a failed set is never banner evidence (the toast goes out only with a real banner and a covered field or failed click). "Remember me" on screen: Toggle pattern (`desktop_ui_toggle`), then Invoke, then the label; `rememberTicked` comes from the read-back state |+ **Unmeasured, stated:** what this host shows after "Log in" (a wrong password, a second step: rejection markers cannot be read from a blind tree, so an unclosed window is `sign_in_unconfirmed`, never retried); whether "Remember me"
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.25",+  "version": "0.9.26",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
references/launch-and-sign-in.md+9
@@ -225,6 +225,15 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE   that is left on page 2; nothing is retried or worked around. ab issue #237   asks for a fill verb that keeps the secret inside ab. Other vault refusals   (consent pending or denied) carry `pagePrepared` too.+- **Page 2, read right (0.9.26, measured 2026-10-12).** A page that holds the+  password box is page 2, even though it also shows the user-name box. That box+  is prefilled and READ-ONLY, so the bridge leaves it alone (a read-only+  refusal means "prefilled") and goes on to ask for the password once.+  `cookieBanner` is true only for accept/reject BUTTONS; a "Manage cookies" link+  is not a banner. A failed set is never blamed on a banner, and no cookie toast+  goes out unless a banner is there. "Remember me" is toggled in the background+  and reported from its read-back state. The updater's tree is cold (14 nodes,+  the frame only) until probed, then stays awake (~37 nodes). - **Signing in for the user — `swx_sign_in {reason}`** (0.9.13). Only for a   launch this bridge started whose own launcher is showing the 3DEXPERIENCE ID   window. It reads the window's inventory (control types, names, IsPassword —
skills/solidworks-sign-in/SKILL.md+21−3
@@ -74,14 +74,32 @@ Every line below is marked **[measured]** (seen on a real machine, 2026-10-02 to   host (ValuePattern accepted), unlike the Visualize host. But the first set   left the user name followed by a stray "s". Set "" first, then the value.   That fixed it.+- [measured] The tree has two states on this host. COLD it is the frame only (14+  nodes). After any `desktop_find_control` or `desktop_ui_from_point` probing+  it stays AWAKE at about 37 nodes, with the whole page: on page 2, Edit+  `username` (prefilled), Edit `password`, a real CheckBox "Remember me "+  (on screen, settable, with a toggle pattern), Button "Log in", Button "Back",+  and links "Forgot your password?", "English", "Help", "Manage cookies".+  Read the tree first; fall back to `desktop_find_control` when it is cold.+- [measured] PAGE 2 IS THE PAGE WITH THE PASSWORD BOX, whatever else is on it.+  The user-name box is there too, prefilled and READ-ONLY: a set answers+  "Cannot call SetValue on read only value". Leave a prefilled name alone, take+  a read-only refusal to mean "prefilled", and never let the name step block+  the password step.+- [measured] A "Manage cookies" LINK is page furniture, not a cookie banner. A+  banner has accept/reject BUTTONS ("Accept All", "Manage my preferences", as on+  the Visualize host). A failed set is never evidence of a banner. Tell the user+  about a banner only when one is really there. - [measured] `desktop_find_control`'s reply carries NO `value`, and the tree has   no page, so on this host the user name cannot be read back. `swx_sign_in`   says `readBack: "unreadable"` in its steps rather than claiming a match. - [measured] `desktop_ui_click {hwnd, name:"Continue"}` (background Invoke)   moved to page 2.-- [unmeasured] Whether "Remember me" can be read once clicked here. The bridge-  clicks the label (only while the user is idle, with the window in front) and-  reports `rememberTicked: null`.+- [unmeasured] Whether a background toggle ticks "Remember me" here. The bridge+  tries the Toggle pattern, then an Invoke, reads the box's state back, and+  claims a tick only if the box says so. When the tree is cold and only a label+  is found, it clicks the label (user idle, window in front) and reports+  `rememberTicked: null`. - [unmeasured] What this host shows after "Log in": a wrong password or a   second step. Read it; never retry. 
src/signin.rs+114−31
@@ -168,6 +168,10 @@ pub struct Element {     /// 2026-10-12, 14 nodes), so its controls are found one by one by name /     /// automation id. Such an element is targeted by its automation id.     pub by_find: bool,+    /// Does the field already hold text? (A non-empty `value` in ab's node —+    /// the value itself is NEVER copied.) MEASURED 2026-10-12: on page 2 the+    /// user-name edit is prefilled and READ-ONLY.+    pub has_value: Option<bool>, }  impl Element {@@ -198,6 +202,9 @@ impl Element {         if self.by_find {             v["locatedBy"] = json!("desktop_find_control");         }+        if let Some(h) = self.has_value {+            v["hasValue"] = json!(h);+        }         v     }     fn is(&self, t: &str) -> bool {@@ -243,6 +250,7 @@ fn element_of(m: &serde_json::Map<String, Value>) -> Option<Element> {         rect: m.get("rect").and_then(rect_xywh),         center: m.get("rect").and_then(rect_center),         by_find: false,+        has_value: m.get("value").and_then(|x| x.as_str()).map(|v| !v.is_empty()),     };     // THE PASSWORD FIELD (MEASURED 2026-10-02: ab's nodes carry no     // `isPassword` key): an explicit password flag, OR an edit whose name /@@ -697,13 +705,18 @@ const REMEMBER: &[&str] = &["remember", "stay signed", "keep me signed", "keep m const USER_FIELD: &[&str] = &["email", "user", "login", "identifier", "account"]; /// The cookie banner's buttons — a consent choice that is the USER's: never /// clicked (MEASURED 2026-10-02: "Accept All", "Manage my preferences").-const COOKIE_CHOICES: &[&str] = &["accept all", "accept all cookies", "manage my preferences", "reject all", "accept"];--/// Is a cookie banner on the page? Reported, never answered.+const COOKIE_CHOICES: &[&str] = &["accept all", "accept all cookies", "manage my preferences", "reject all", "reject", "accept", "allow all"];++/// Is a cookie banner on the page? Reported, never answered. A banner is an+/// accept / reject / preferences BUTTON (measured 2026-10-02 on the Visualize+/// host: "Accept All", "Manage my preferences"). A lone "Manage cookies"+/// HYPERLINK is page furniture, not a banner — MEASURED 2026-10-12 on the+/// SWXDesktopUpdate host, where 0.9.25 called that link a banner and toasted+/// the user to answer a banner that did not exist. pub fn cookie_banner(inv: &[Element]) -> bool {     inv.iter().any(|e| {-        let n = e.name.trim().to_lowercase();-        (e.is("button") && COOKIE_CHOICES.contains(&n.as_str())) || n.contains("cookies")+        let n = e.name.trim().trim_end_matches(['.', '…']).to_lowercase();+        e.is("button") && (COOKIE_CHOICES.contains(&n.as_str()) || (n.contains("cookie") && (n.contains("accept") || n.contains("reject") || n.contains("allow"))))     }) } @@ -1366,7 +1379,11 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {     };     let mut needles: Vec<String> = listed_user.iter().cloned().collect();     let mut steps: Vec<Value> = Vec::new();-    let mut pages: Vec<Value> = vec![json!({"page": 1, "inventory": inv_j, "wake": wake_1, "located": located_1, "cookieBanner": banner})];+    // A page with the PASSWORD edit is page 2 (or a one-page form), whatever+    // else it holds (0.9.26: measured, page 2 also shows the user-name edit and+    // was called page 1).+    let first_page: u8 = if form.password.is_some() { 2 } else { 1 };+    let mut pages: Vec<Value> = vec![json!({"page": first_page, "inventory": inv_j, "wake": wake_1, "located": located_1, "cookieBanner": banner})];     let mut remember_ticked = Value::Null;     let mut vault = json!({"host": host});     // No user name listed beside the host: the reveal is the only source of@@ -1389,7 +1406,7 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {     let mut closed_early = false;     let mut pw_els: Vec<Element> = read.elements.clone();     let mut pw_form: Form = form.clone();-    let mut pw_page: u8 = if lw.page == Some(2) { 2 } else { 1 };+    let mut pw_page: u8 = if lw.page == Some(2) { 2 } else { first_page };     // 3. PAGE ONE when it asks only for the user name (MEASURED 2026-10-02:     //    "Email or username" + Continue): the name, Continue, then wait for the     //    page to CHANGE and recognize the password page.@@ -1461,11 +1478,30 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {             drop(secret);             return scrub(&fill_failure(FillErr::Failed { step: "user name".into(), why: "the saved login has no user name to type".into(), observed: None, may_be_banner: false }, banner, &pages, &steps, &vault), &needles);         };-        if let Err(fe) = fill_field(hwnd, u_el, u, true, &mut steps) {-            drop(secret);-            return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);+        // PAGE 2 (MEASURED 2026-10-12): the name is PREFILLED and READ-ONLY —+        // a set answers "Cannot call SetValue on read only value". A prefilled+        // name is left alone; a read-only refusal means prefilled; and the name+        // step never blocks the password step.+        if u_el.has_value == Some(true) {+            steps.push(json!({"step": "user name", "ok": true, "nameField": "prefilled", "via": "skipped (the field already holds a name)"}));+            user_filled = true;+        } else {+            match fill_field(hwnd, u_el, u, true, &mut steps) {+                Ok(()) => user_filled = true,+                Err(FillErr::ReadOnly(why)) => {+                    steps.push(json!({"step": "user name", "ok": true, "nameField": "prefilled", "via": "read-only", "abSaid": why}));+                    user_filled = true;+                }+                Err(FillErr::NeedsIdle(why)) => {+                    drop(secret);+                    return scrub(&fill_failure(FillErr::NeedsIdle(why), banner, &pages, &steps, &vault), &needles);+                }+                Err(FillErr::Failed { step, why, .. }) => {+                    steps.push(json!({"step": "user name", "ok": false, "nameField": "notSet", "failedAt": step, "why": why,+                                      "effect": "not blocking: the password page goes on"}));+                }+            }         }-        user_filled = true;         pw_form = Form { user: None, ..form.clone() };     }     // 4. THE PASSWORD: the reveal, ONCE, only now — the page is prepared.@@ -1480,7 +1516,7 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {                 }                 other => {                     let prepared = json!({"page": pw_page, "userNameFilled": user_filled});-                    let out = vault_refusal(other, json!({"pages": pages, "steps": steps, "vault": vault}), Some(prepared));+                    let out = vault_refusal(other, json!({"pages": pages, "steps": steps, "vault": vault, "cookieBanner": banner}), Some(prepared));                     return scrub(&out, &needles);                 }             },@@ -1584,9 +1620,43 @@ fn tick_remember(hwnd: i64, page: &[Element], rm: &Element) -> Value {     if is_on(rm.toggle_state.as_deref()) {         return json!("alreadyOn");     }-    if !rm.offscreen() && rm.invokable != Some(false) {-        let r = crate::caller::ad_callback("desktop_ui_click", merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": FILL_REASON})), AB_TIMEOUT);-        return json!({"ticked": ab_ok(&r), "via": "uiaInvoke"});+    // READ THE TOGGLE BACK: a tick is claimed only if the box says so.+    let read_toggle = || {+        read_page(hwnd)+            .ok()+            .and_then(|r| r.elements.into_iter().find(|e| e.is("checkbox") && e.name == rm.name))+            .and_then(|e| e.toggle_state)+    };+    let settle_on = |first: Option<String>| {+        let until = std::time::Instant::now() + Duration::from_millis(1500);+        let mut after = first;+        while !is_on(after.as_deref()) && std::time::Instant::now() < until {+            std::thread::sleep(Duration::from_millis(150));+            after = read_toggle();+        }+        after+    };+    // AN ON-SCREEN CHECKBOX (MEASURED 2026-10-12 on the SWXDesktopUpdate host:+    // a real CheckBox "Remember me ", settable, with a toggle pattern): the+    // Toggle pattern in the background first, then an Invoke; the label click+    // below only if neither ticks it.+    if !rm.offscreen() {+        let args = merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": FILL_REASON}));+        let mut via = "uiaToggle";+        let mut r = crate::caller::ad_callback("desktop_ui_toggle", args.clone(), AB_TIMEOUT);+        if !ab_ok(&r) && rm.invokable != Some(false) {+            via = "uiaInvoke";+            r = crate::caller::ad_callback("desktop_ui_click", args, AB_TIMEOUT);+        }+        if ab_ok(&r) {+            let after = settle_on(read_toggle());+            if is_on(after.as_deref()) {+                return json!({"ticked": true, "via": via, "toggleStateAfter": after});+            }+            if after.is_none() {+                return json!({"ticked": Value::Null, "via": via, "why": "the toggle state could not be read back"});+            }+        }     }     let stem = rm.name.trim().trim_end_matches(['.', '…']).to_lowercase();     let Some(label) = page.iter().find(|e| {@@ -1611,18 +1681,7 @@ fn tick_remember(hwnd: i64, page: &[Element], rm: &Element) -> Value {     let (x, y) = label.center.unwrap_or_default();     let r = crate::caller::ad_callback("desktop_click", json!({"x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);     // READ THE TOGGLE AGAIN: the label click is evidence only if the box says so.-    let read_toggle = || {-        inventory(hwnd)-            .ok()-            .and_then(|r| r.elements.into_iter().find(|e| e.is("checkbox") && e.name == rm.name))-            .and_then(|e| e.toggle_state)-    };-    let until = std::time::Instant::now() + Duration::from_millis(1500);-    let mut after = read_toggle();-    while !is_on(after.as_deref()) && std::time::Instant::now() < until {-        std::thread::sleep(Duration::from_millis(150));-        after = read_toggle();-    }+    let after = settle_on(read_toggle());     json!({"ticked": ab_ok(&r) && is_on(after.as_deref()), "via": "labelClick", "label": label.name, "toggleStateAfter": after}) } @@ -1645,7 +1704,17 @@ fn click_remember_label(hwnd: i64, label: &Element) -> Value {            "state": "unreadable — no CheckBox was found on this host, so whether it is ticked cannot be read"}) } +/// "Cannot call SetValue on read only value" (measured), and its spellings.+fn is_read_only(why: &str) -> bool {+    let w = why.to_lowercase();+    w.contains("read only") || w.contains("readonly") || w.contains("read-only")+}+ pub(crate) enum FillErr {+    /// The field refused a set as READ-ONLY (MEASURED 2026-10-12: page 2's+    /// prefilled user name — "UIA SetValue failed: Cannot call SetValue on read+    /// only value"). Evidence of a prefilled field, never of a banner.+    ReadOnly(String),     /// The user is typing (or the idle time cannot be read): no keystroke sent.     NeedsIdle(String),     /// `may_be_banner`: a failure a cookie banner covering the form could cause@@ -1808,7 +1877,10 @@ fn set_cleared(hwnd: i64, el: &Element, typed: &str, read_back: bool, label: &st             return Ok(false);         }         steps.push(json!({"step": format!("{label}: clear"), "ok": false, "via": "uiaValuePattern"}));-        return Err(FillErr::Failed { step: format!("{label} (UIA clear)"), why, observed: None, may_be_banner: true });+        if is_read_only(&why) {+            return Err(FillErr::ReadOnly(why));+        }+        return Err(FillErr::Failed { step: format!("{label} (UIA clear)"), why, observed: None, may_be_banner: false });     }     let cleared = field_value(hwnd, el);     if read_back {@@ -1824,7 +1896,11 @@ fn set_cleared(hwnd: i64, el: &Element, typed: &str, read_back: bool, label: &st     let r = set(typed);     if !ab_ok(&r) {         steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern", "cleared": true}));-        return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why: ab_error(&r), observed: None, may_be_banner: true });+        let why = ab_error(&r);+        if is_read_only(&why) {+            return Err(FillErr::ReadOnly(why));+        }+        return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: false });     }     if !read_back {         steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true, "readBack": "notReadPassword"}));@@ -1889,7 +1965,13 @@ fn fill_field(hwnd: i64, el: &Element, text: &[u8], read_back: bool, steps: &mut     // The measured refusal: "control found but has NO ValuePattern".     if !why.to_lowercase().contains("no valuepattern") {         steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern"}));-        return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: true });+        // A FAILED SET IS NEVER EVIDENCE OF A COOKIE BANNER (0.9.26): measured+        // 2026-10-12, "Cannot call SetValue on read only value" on a page with+        // no banner was reported as one, and the user was told to answer it.+        if is_read_only(&why) {+            return Err(FillErr::ReadOnly(why));+        }+        return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: false });     }     }     // ── REAL KEYSTROKES ──────────────────────────────────────────────────────@@ -1990,6 +2072,7 @@ fn fill_failure(fe: FillErr, banner: bool, pages: &[Value], steps: &[Value], vau             );         }         FillErr::Failed { step, why, observed, may_be_banner } => (step, why, observed, may_be_banner),+        FillErr::ReadOnly(why) => ("set of a read-only field".to_string(), why, None, false),     };     let (step, e, observed, may_be_banner) = (step, e, observed, may_be_banner);     if banner && may_be_banner {@@ -2006,7 +2089,7 @@ fn fill_failure(fe: FillErr, banner: bool, pages: &[Value], steps: &[Value], vau         "sign_in_fill_failed",         format!("the {step} in the sign-in window failed: {e}"),         "Nothing further was submitted and Enter was never pressed. The user can sign in by hand.",-        json!({"pages": pages, "steps": steps, "vault": vault, "observed": observed}),+        json!({"pages": pages, "steps": steps, "vault": vault, "observed": observed, "cookieBanner": banner}),     ) } 
tests/signin.rs+119−4
@@ -160,6 +160,8 @@ fn close_window_on_submit(m: &Machine, ab: &common::AbStub) { fn the_bridge_signs_in_from_the_vault_and_the_password_appears_nowhere() {     let m = Machine::new("signin-ok");     let ab = ab_for(form(vec![]), vault_ok());+    // 0.9.26: "Remember me" is toggled in the background and READ BACK.+    ab.on_call_set("desktop_ui_toggle", "desktop_ui_tree", serde_json::from_str(&form(vec![]).to_string().replace(r#""toggleState":"Off""#, r#""toggleState":"On""#)).unwrap());     let b = m.boot(TempDir::new("signin-ok-scratch"), &ab, &[]);     let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "the user asked for SOLIDWORKS"}))];     let st = wait_for_sign_in_stage(&b);@@ -179,7 +181,7 @@ fn the_bridge_signs_in_from_the_vault_and_the_password_appears_nowhere() {     check!(         b,         "swx_sign_in: signed in, verified by the window closing, Remember me ticked, consent reported",-        r["success"] == true && r["signedIn"] == true && r["rememberTicked"]["ticked"] == true+        r["success"] == true && r["signedIn"] == true && r["rememberTicked"]["ticked"] == true && r["rememberTicked"]["via"] == "uiaToggle"             && r["vault"]["consent"] == "perUse" && !m.sign_in_up(),         s(&r)     );@@ -770,11 +772,15 @@ fn a_cookie_banner_in_the_way_is_the_users_choice() {     b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));     wait_for_sign_in_stage(&b);     let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+    // 0.9.26: A FAILED SET IS NEVER EVIDENCE OF A BANNER (measured 2026-10-12:+    // a read-only refusal was blamed on a banner that did not exist, and the+    // user was told to answer it). The banner is reported, never answered, and+    // the user is told about it only when a field is seen COVERED by it.     check!(         b,-        "the fill failed with the banner up: sign_in_needs_user naming the banner, the user told, the banner never answered",-        r["errorCode"] == "sign_in_needs_user" && r["cookieBanner"] == true-            && !ab.calls_to("notify_user").is_empty()+        "a failed set with a banner on the page: sign_in_fill_failed, the banner reported, no cookie toast, the banner never answered",+        r["errorCode"] == "sign_in_fill_failed" && r["cookieBanner"] == true+            && !ab.calls_to("notify_user").iter().any(|c| c["args"]["message"].as_str().unwrap_or("").to_lowercase().contains("cookie"))             && !ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["name"] == "Accept All" || c["args"]["name"] == "Manage my preferences"),         s(&r)     );@@ -1368,3 +1374,112 @@ fn the_reveal_refused_by_ab_policy_leaves_the_page_prepared_for_the_user() {     let all = everything_but_the_fill(&b, &ab, &[r]);     check!(b, "the user name appears in no reply, log or other request", !all.contains(USER), String::new()); }++// ── 0.9.26: the AWAKE page 2 on the updater host, measured 2026-10-12 ───────++/// 37 nodes once probed (the cold tree is the 14-node frame): both edits — the+/// user name prefilled and READ-ONLY — a real CheckBox "Remember me ", Log in,+/// Back, and hyperlinks including "Manage cookies". NO cookie banner.+fn awake_page_two() -> Value {+    json!({"success": true, "status": "ok", "hwnd": UPDATE_HWND, "nodeCount": 37, "truncated": false,+           "window": {"title": "3DEXPERIENCE", "rect": {"x": 828, "y": 273, "w": 1316, "h": 1076}},+           "root": {"role": "Window", "name": "3DEXPERIENCE", "automationId": "SWXDesktopUpdateForm", "children": [+               {"role": "Document", "name": "Login | 3DEXPERIENCE ID | Dassault Systèmes", "automationId": "RootWebArea", "children": [+                   {"role": "Edit", "name": "Email or username", "automationId": "username", "invokable": false, "settable": true,+                    "rect": {"x": 1286, "y": 707, "w": 400, "h": 40, "centerX": 1486, "centerY": 727}},+                   {"role": "Edit", "name": "Password ", "automationId": "password", "invokable": false, "settable": true,+                    "rect": {"x": 1286, "y": 836, "w": 400, "h": 40, "centerX": 1486, "centerY": 856}},+                   {"role": "CheckBox", "name": "Remember me ", "automationId": "", "invokable": false, "settable": true, "toggleState": "Off",+                    "rect": {"x": 1176, "y": 915, "w": 16, "h": 16, "centerX": 1184, "centerY": 923}},+                   {"role": "Button", "name": "Log in", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Button", "name": "Back", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Hyperlink", "name": "Forgot your password?", "automationId": "", "invokable": true},+                   {"role": "Hyperlink", "name": "English", "automationId": "", "invokable": true},+                   {"role": "Hyperlink", "name": "Help", "automationId": "", "invokable": true},+                   {"role": "Hyperlink", "name": "Manage cookies", "automationId": "", "invokable": true}+               ]}+           ]}})+}++const READ_ONLY: &str = "UIA SetValue failed: Cannot call SetValue on read only value";++#[test]+fn the_awake_page_two_skips_the_prefilled_name_and_a_cookie_link_is_not_a_banner() {+    let m = update_machine("signin-awake-two");+    let ab = update_ab();+    update_page_two(&ab);+    ab.set_response("desktop_ui_tree", awake_page_two());+    ab.when("desktop_ui_set", json!({"name": "Email or username"}), json!({"success": false, "error": READ_ONLY}));+    ab.set_response("pup_credential_reveal", json!({"success": false, "error": ORIGIN_POLICY}));+    let b = m.boot(TempDir::new("signin-awake-two-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20"), ("SWX_SIGN_IN_CONSENT_SECS", "3")]);+    let r = b.call("swx_sign_in", json!({"reason": "Drew: sign me in", "userAuthorized": true, "window": UPDATE_HWND}));+    check!(+        b,+        "PAGE 2 (it has the password edit, whatever else it holds): the read-only name is 'prefilled', the reveal asked ONCE, sign_in_vault_policy with pagePrepared {page:2, userNameFilled:true}",+        r["errorCode"] == "sign_in_vault_policy" && r["pages"][0]["page"] == 2 && r["pages"][0]["located"]["source"] == "tree"+            && r["pagePrepared"]["page"] == 2 && r["pagePrepared"]["userNameFilled"] == true+            && r["steps"].as_array().map(|a| a.iter().any(|st| st["nameField"] == "prefilled")).unwrap_or(false)+            && ab.calls_to("pup_credential_reveal").len() == 1,+        s(&r)+    );+    check!(+        b,+        "the 'Manage cookies' LINK is not a banner: cookieBanner false, and no toast about cookies",+        r["cookieBanner"] == false && r["pages"][0]["cookieBanner"] == false+            && !ab.calls_to("notify_user").iter().any(|c| c["args"]["message"].as_str().unwrap_or("").to_lowercase().contains("cookie")),+        s(&r)+    );++    // The same page with the vault answering, and "Log in" failing: a failed+    // fill with NO banner never tells the user to answer a banner.+    let m = update_machine("signin-awake-two-fail");+    let ab = update_ab();+    update_page_two(&ab);+    ab.set_response("desktop_ui_tree", awake_page_two());+    ab.when("desktop_ui_set", json!({"name": "Email or username"}), json!({"success": false, "error": READ_ONLY}));+    ab.when("desktop_ui_click", json!({"name": "Log in"}), json!({"success": false, "error": "Invoke failed"}));+    let b = m.boot(TempDir::new("signin-awake-two-fail-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20")]);+    let r = b.call("swx_sign_in", json!({"reason": "Drew: sign me in", "userAuthorized": true, "window": UPDATE_HWND}));+    check!(+        b,+        "no banner, a failed press: sign_in_fill_failed and NO cookie toast; the password went in by automation id",+        r["errorCode"] == "sign_in_fill_failed"+            && !ab.calls_to("notify_user").iter().any(|c| c["args"]["message"].as_str().unwrap_or("").to_lowercase().contains("cookie"))+            && ab.calls_to("desktop_ui_set").iter().any(|c| c["args"]["text"] == PW),+        s(&r)+    );+    let all = everything_but_the_fill(&b, &ab, &[r]);+    check!(b, "the password appears nowhere", !all.contains(PW), String::new());++    // The vault answers and Log in closes the window. "Remember me" is a real+    // on-screen CheckBox: toggled in the background — and a toggle ab calls+    // done while the box still reads Off is NOT a tick.+    let m = update_machine("signin-awake-two-ok");+    let ab = update_ab();+    update_page_two(&ab);+    ab.set_response("desktop_ui_tree", awake_page_two());+    ab.when("desktop_ui_set", json!({"name": "Email or username"}), json!({"success": false, "error": READ_ONLY}));+    let b = m.boot(TempDir::new("signin-awake-two-ok-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20"), ("SWX_SIGN_IN_VERIFY_SECS", "8")]);+    let r = std::thread::scope(|sc| {+        sc.spawn(|| drive_update_login(&m, &ab));+        b.call("swx_sign_in", json!({"reason": "Drew: sign me in", "userAuthorized": true, "window": UPDATE_HWND}))+    });+    check!(+        b,+        "signed in; the toggle was tried in the background, and the box reading Off afterwards is reported as NOT ticked",+        r["signedIn"] == true && !ab.calls_to("desktop_ui_toggle").is_empty() && r["rememberTicked"]["ticked"] == false,+        s(&r)+    );+}++/// Log in → the window closes.+fn drive_update_login(m: &Machine, ab: &common::AbStub) {+    let deadline = Instant::now() + Duration::from_secs(20);+    while Instant::now() < deadline {+        if ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["name"] == "Log in") {+            let _ = std::fs::remove_file(m.dir.join("sign-in.json"));+            return;+        }+        std::thread::sleep(Duration::from_millis(15));+    }+}
uninstall.sh