app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.9.26 source
13 files changed
+280−44
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.shpackage.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