Skip to content

fix(connect): detect alertdialog/aria-modal and wait for late-mounting invite modal#458

Open
ivangotti wants to merge 4 commits into
stickerdaniel:mainfrom
ivangotti:fix/455-dialog-selector-and-mount-race
Open

fix(connect): detect alertdialog/aria-modal and wait for late-mounting invite modal#458
ivangotti wants to merge 4 commits into
stickerdaniel:mainfrom
ivangotti:fix/455-dialog-selector-and-mount-race

Conversation

@ivangotti

Copy link
Copy Markdown

Follow-up to #456 (merged). The 2-button-gating handling shipped in #456 is correct for the case where the dialog actually opens, but on real accounts post-deploy connect_with_person was still returning connect_unavailable for every with-note attempt — because _dialog_is_open was failing one layer upstream and the secondary-button branch never ran.

This PR fixes the upstream gate. The 2-button-gating handling from #456 is preserved verbatim and is now reachable.

Re #455 (post-#456 follow-up). Distinct from #454 (different failure stage — that one bails before reaching the deeplink gate) and #432 (composer-overlay collision — orthogonal).

Real root cause

Two issues in _dialog_is_open / _DIALOG_SELECTOR, both visible in trace run-8fic76qw from a failing 8/8 production run on 2026-05-20 (post-#456 deploy):

  1. Selector was too narrow. The "Add a note to your invitation?" gating dialog is an artdeco-modal whose root carries role="alertdialog" and/or aria-modal="true", neither of which matched dialog[open], [role="dialog"]. Trace screenshots show the dialog visibly mounted at the exact DOM moment _dialog_is_open was returning False — the helper just couldn't see it.

  2. Race against modal mount. _dialog_is_open short-circuited on count() == 0 before spending its timeout budget. After goto(/preload/custom-invite/?vanityName=) resolves on domcontentloaded, the modal mounts a few hundred ms later, so the count check fired against an empty DOM and returned False immediately. wait_for(state="visible") is the right primitive — it polls until the element exists and is visible.

The two effects compound. Even on accounts where LinkedIn picks role="dialog" and the selector would have matched, the eager count() made the helper racy. Even on a slow account where waiting longer would have helped, the wrong selector would still miss the alertdialog mount.

Evidence

From the trace artifact ~/.linkedin-mcp/trace-runs/run-8fic76qw/ (real failing run, 2026-05-20, account-wide reproduction matching #455's reporter):

  • Step 5: goto preload/custom-invite/?vanityName=meenal-ganesh-b1710321. Screenshot 005-extractor-after-goto.png shows the gating dialog fully mounted and visible (X close + "Add a note" + "Send without a note"). Body marker: "Dialog content start. Add a note to your invitation? Personalize your invitation to Meenal Ganesh by adding a note." — verbatim DOM text.
  • The dialog is structurally identical across Brian W., Igor Dimoski, Sandro Valentini (screenshots 013, 017, 021).
  • For Greg Sandzimier (run-3mu94g3t, 2026-05-19, before the gating dialog rolled out broadly to this account), the trace shows a verification scrape immediately after the deeplink — i.e. submitted=True, dialog detected, "Send without a note" clicked, profile transitioned to "More Pending Message". Invite landed.
  • For Brian / Igor / Sandro on 2026-05-20, there is no verification scrape after the deeplink — the trace jumps straight to the next person's profile. submitted=Falseconnect_unavailable. Same dialog DOM, different _dialog_is_open result. Only the LinkedIn-side rollout state differed.

The fix

 _DIALOG_SELECTOR = (
+    'dialog[open], '
+    '[role="dialog"], '
+    '[role="alertdialog"], '
+    '[aria-modal="true"]'
 )

 _DIALOG_TEXTAREA_SELECTOR = (
+    '[role="dialog"] textarea, '
+    '[role="alertdialog"] textarea, '
+    '[aria-modal="true"] textarea, '
+    'dialog textarea'
 )

 async def _dialog_is_open(self, *, timeout: int = 1000) -> bool:
-    locator = self._page.locator(_DIALOG_SELECTOR)
+    locator = self._page.locator(_DIALOG_SELECTOR).first
     try:
-        if await locator.count() == 0:
-            return False
-        await locator.first.wait_for(state="visible", timeout=timeout)
+        await locator.wait_for(state="visible", timeout=timeout)
         return True
     except Exception:
         return False

Per-call sites: every existing reference to _DIALOG_SELECTOR and _DIALOG_TEXTAREA_SELECTOR (button collection in _submit_invite_dialog, textarea fill in _fill_dialog_textarea, dismiss-on-hidden) gets the broadened scope automatically.

The 2-button-gating handling from #456 is preserved — with the dialog now correctly detected, the existing btn_count >= 2 / nth(btn_count - 2) branch fires and reveals the textarea on the new gating layout, while the no-note path still hits "Send without a note" via _click_dialog_primary_button.

Tests

  • New test_dialog_selector_matches_alertdialog_and_aria_modal — guards every required ARIA pattern in _DIALOG_SELECTOR and _DIALOG_TEXTAREA_SELECTOR. Future narrowing regressions fail this test before merging.
  • New test_dialog_is_open_waits_for_late_mounting_dialog — asserts the helper spends its budget on wait_for(visible) rather than reading count() eagerly.
  • Existing test_submit_invite_dialog_handles_two_button_gating_dialog (from fix(connect): match alertdialog/aria-modal and wait for late-mounting invite modal #456) and the rest of TestConnectWithPerson continue to pass unchanged.
  • Full suite: 510 passed (was 508 before this branch).

Verification confidence

This is best-effort code reading + DOM trace evidence — I did not drive a real browser to verify the new selector against the live LinkedIn DOM. What I do know:

  • The gating dialog is visible in the real trace screenshots.
  • The body's accessible text ("Dialog content start. ...") is fetched via document.body.innerText, so the dialog is in the DOM.
  • _dialog_is_open was returning False against that DOM; only an out-of-scope selector or a race against mount can produce that.
  • The replacement selector covers the three modal-root ARIA patterns LinkedIn ships across artdeco-modal versions, and the wait_for(visible) pattern is what the rest of the codebase already uses for modal detection (cf. extract_overlay_page and handle_modal_close).

If LinkedIn ever serves a modal with neither role attribute nor aria-modal, this fix won't catch it — but I've not seen evidence of that variant in any trace, and adding .artdeco-modal__content would risk matching non-modal artdeco UI elsewhere on the page.

What this PR does not do

🤖 Generated with Claude Code

… modal

The 2-button-gating fix in a48fccc was downstream of the actual failure:
on accounts/profiles where the dialog opens correctly, the previous
``_dialog_is_open`` was returning False before the dialog had a chance
to mount, so the secondary-button click that fix targeted never ran.

Two issues, both rooted in `_DIALOG_SELECTOR` / `_dialog_is_open`:

1. **Selector was too narrow.** The "Add a note to your invitation?"
   gating dialog (and likely the inner artdeco-modal that hosts the
   note textarea) is rendered as ``role="alertdialog"`` with
   ``aria-modal="true"`` — neither matched
   ``dialog[open], [role="dialog"]``. Trace screenshots from a real
   failing run (``run-8fic76qw``) show the dialog visibly mounted at
   the same DOM moment ``_dialog_is_open`` was returning False.

2. **Race against modal mount.** ``_dialog_is_open`` short-circuited on
   ``count() == 0`` before spending its timeout budget. After
   ``goto(/preload/custom-invite/?vanityName=)`` resolves on
   ``domcontentloaded``, the modal mounts a few hundred ms later, so
   the count check fired against an empty DOM and returned False
   immediately. ``wait_for(state="visible")`` is the right primitive
   here — it polls until the element exists *and* is visible.

The minimal fix:

* Broaden ``_DIALOG_SELECTOR`` to ``dialog[open], [role="dialog"],
  [role="alertdialog"], [aria-modal="true"]`` — covers every modal
  variant LinkedIn ships today across the artdeco-modal versions, with
  the same scope applied to ``_DIALOG_TEXTAREA_SELECTOR`` so the note
  textarea is still found under the new dialog roots.
* Replace the eager ``count()`` short-circuit in ``_dialog_is_open``
  with ``wait_for(state="visible", timeout=timeout)`` directly. The
  helper's contract — "is a dialog currently open" — already implies
  waiting up to ``timeout`` for one, and every caller already passes
  the timeout it actually wants.

The 2-button gating fix from a48fccc is preserved: with the dialog
correctly detected, the existing ``btn_count >= 2`` /
``nth(btn_count - 2)`` branch fires and reveals the textarea on the
new gating layout, while the no-note path still clicks
"Send without a note" via ``_click_dialog_primary_button``.

Tests:
* ``test_dialog_selector_matches_alertdialog_and_aria_modal`` —
  guards every required ARIA pattern in the selector constants.
* ``test_dialog_is_open_waits_for_late_mounting_dialog`` — asserts the
  helper spends its budget on ``wait_for(visible)`` rather than
  reading ``count()`` eagerly.
* Existing ``test_submit_invite_dialog_handles_two_button_gating_dialog``
  and the rest of ``TestConnectWithPerson`` continue to pass unchanged.
* Full suite: 510 passed (was 508).

Re stickerdaniel#455.
@greptile-apps

greptile-apps Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR updates the LinkedIn connect invite dialog handling. It changes:

  • Waits for late-mounting invite dialogs instead of checking count eagerly.
  • Scopes dialog button searches through :is(...) selectors.
  • Adds fallback handling for the Premium custom-note upsell.
  • Adds regression coverage for modal selector support and delayed dialog mounting.

Confidence Score: 3/5

This should be fixed before merging.

  • The invite dialog wait can still miss the production modal shape described by the PR.
  • The added selector regression test expects strings that are not present in the current selector constants.
  • The core with-note connect path can still return connect_unavailable before button handling runs.

linkedin_mcp_server/scraping/extractor.py

Important Files Changed

Filename Overview
linkedin_mcp_server/scraping/extractor.py Updates dialog detection and invite submission flow, but the modal-root selectors still miss the alertdialog and aria-modal cases the PR is meant to handle.
tests/test_scraping.py Adds regression tests for selector breadth and delayed modal mounting.

Comments Outside Diff (1)

  1. linkedin_mcp_server/scraping/extractor.py, line 96-97 (link)

    P1 Broaden dialog selectors

    The modal wait now spends the timeout budget on _DIALOG_SELECTOR, but the selector itself still only matches native dialog[open] and [role="dialog"]. When LinkedIn serves the invite gate as [role="alertdialog"] or [aria-modal="true"], _dialog_is_open() waits on the wrong selector and returns false, so connect_with_person still reports connect_unavailable before reaching the button handling. The textarea selector has the same gap, so note filling and upsell detection also miss those modal roots.

    Context Used: CLAUDE.md (source)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: linkedin_mcp_server/scraping/extractor.py
    Line: 96-97
    
    Comment:
    **Broaden dialog selectors**
    
    The modal wait now spends the timeout budget on `_DIALOG_SELECTOR`, but the selector itself still only matches native `dialog[open]` and `[role="dialog"]`. When LinkedIn serves the invite gate as `[role="alertdialog"]` or `[aria-modal="true"]`, `_dialog_is_open()` waits on the wrong selector and returns false, so `connect_with_person` still reports `connect_unavailable` before reaching the button handling. The textarea selector has the same gap, so note filling and upsell detection also miss those modal roots.
    
    
    
    **Context Used:** CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=e3726abd-137d-439d-b03c-d01e1ba139d4))
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
linkedin_mcp_server/scraping/extractor.py:96-97
**Broaden dialog selectors**

The modal wait now spends the timeout budget on `_DIALOG_SELECTOR`, but the selector itself still only matches native `dialog[open]` and `[role="dialog"]`. When LinkedIn serves the invite gate as `[role="alertdialog"]` or `[aria-modal="true"]`, `_dialog_is_open()` waits on the wrong selector and returns false, so `connect_with_person` still reports `connect_unavailable` before reaching the button handling. The textarea selector has the same gap, so note filling and upsell detection also miss those modal roots.

```suggestion
_DIALOG_SELECTOR = 'dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]'
_DIALOG_TEXTAREA_SELECTOR = '[role="dialog"] textarea, [role="alertdialog"] textarea, [aria-modal="true"] textarea, dialog textarea'
```

Reviews (4): Last reviewed commit: "fix(connect): detect Premium upsell on n..." | Re-trigger Greptile

ivangotti pushed a commit to ivangotti/linkedin-mcp-server that referenced this pull request May 21, 2026
The comma-separated _DIALOG_SELECTOR (dialog[open], [role="dialog"],
[role="alertdialog"], [aria-modal="true"]) breaks descendant selectors:
appending " button" only attaches to the LAST arm, so the other 3 arms
match dialog roots instead of buttons.

For invite modals rooted at role="alertdialog" (the live LinkedIn layout),
this means _click_dialog_primary_button can click the modal root instead
of "Send", and the gating-button branch in _submit_invite_dialog can
click the root instead of "Add a note" — both leaving the dialog open
and returning connect_unavailable.

Wrap _DIALOG_SELECTOR in :is() at all 3 descendant-button locator
sites to apply the descendant combinator across all selector arms.

Reproduces the failure observed in production after the previous
selector-broadening commit: invites against alertdialog-rooted modals
returned connect_unavailable across multiple profiles. Fix matches
Greptile's review suggestion on PR stickerdaniel#458.
The comma-separated _DIALOG_SELECTOR (dialog[open], [role="dialog"],
[role="alertdialog"], [aria-modal="true"]) breaks descendant selectors:
appending " button" only attaches to the LAST arm, so the other 3 arms
match dialog roots instead of buttons.

For invite modals rooted at role="alertdialog" (the live LinkedIn layout),
this means _click_dialog_primary_button can click the modal root instead
of "Send", and the gating-button branch in _submit_invite_dialog can
click the root instead of "Add a note" — both leaving the dialog open
and returning connect_unavailable.

Wrap _DIALOG_SELECTOR in :is() at all 3 descendant-button locator
sites to apply the descendant combinator across all selector arms.

Reproduces the failure observed in production after the previous
selector-broadening commit: invites against alertdialog-rooted modals
returned connect_unavailable across multiple profiles. Fix matches
Greptile's review suggestion on PR stickerdaniel#458.
@ivangotti

Copy link
Copy Markdown
Author

Greptile's review caught a real bug — confirmed in production.

After the dialog-selector broadening on this PR, my own invite attempts against alertdialog-rooted profiles still returned connect_unavailable. Greptile's diagnosis is exactly right: the descendant button selectors built from {_DIALOG_SELECTOR} button only apply the descendant combinator to the LAST arm of the comma-separated selector list. The other arms match dialog roots instead of buttons, so the click goes to the modal root and "Send" never fires.

Pushed 50a695b wrapping _DIALOG_SELECTOR in :is() at all 3 descendant-button locator sites. Matches Greptile's suggested fix verbatim.

Will verify against my account and report back.

Ivan Gotti added 2 commits May 20, 2026 20:10
…rrow selector

Yesterday's production trace (run-3mu94g3t step 61) shows successful
invite submission to gregsandzimier with the original narrow selector
'dialog[open], [role="dialog"]'. This proves LinkedIn's invite dialog
uses role="dialog" — not role="alertdialog" as previously assumed.

Today's failure traces (run-e0hlk958) show the dialog renders
identically. With the broadened selector, .first picks a hidden
"aria-modal" element elsewhere on the page (e.g. messaging widget),
and wait_for(visible) times out → connect_unavailable.

Reverting _DIALOG_SELECTOR + _DIALOG_TEXTAREA_SELECTOR to narrow form.
Keeps :is() scoping fix and btn_count >= 2 gating-dialog fix from PR
stickerdaniel#456 — both still load-bearing.
…-fallback

REAL ROOT CAUSE for issue stickerdaniel#455 / stickerdaniel#458 'connect_unavailable' failures
in production:

LinkedIn limits free accounts to a small number of personalized notes
per month. After the quota is exhausted, clicking 'Add a note' in the
2-button gating dialog replaces the dialog with a Premium upsell:

  'Send unlimited personalized invites with Premium —
   You're out of free custom notes.'

Previous code waited 3s for the textarea to appear, then failed via
_fill_dialog_textarea returning False, then dismissed and returned
'connect_unavailable' — discarding the connection request entirely
even though the no-note send path was still viable.

Fix: detect the upsell by its content, dismiss it, re-trigger the
invite dialog (URL is unchanged through the upsell), and fall
through to the primary-button click which sends without a note.
note_sent=False is returned so the caller knows the personalization
was dropped.

Verified end-to-end against live LinkedIn: confirmed Premium upsell
appears for an out-of-quota account, dismiss + re-navigate restores
the original gating dialog, primary-button click sends invite.
Profile transitions to 'Pending'. (Tested on babermehkeri and
javargas209.)
ivangotti pushed a commit to ivangotti/linkedin-mcp-server that referenced this pull request May 21, 2026
…-fallback

REAL ROOT CAUSE for issue stickerdaniel#455 / stickerdaniel#458 'connect_unavailable' failures
in production:

LinkedIn limits free accounts to a small number of personalized notes
per month. After the quota is exhausted, clicking 'Add a note' in the
2-button gating dialog replaces the dialog with a Premium upsell:

  'Send unlimited personalized invites with Premium —
   You're out of free custom notes.'

Previous code waited 3s for the textarea to appear, then failed via
_fill_dialog_textarea returning False, then dismissed and returned
'connect_unavailable' — discarding the connection request entirely
even though the no-note send path was still viable.

Fix: detect the upsell by its content, dismiss it, re-trigger the
invite dialog (URL is unchanged through the upsell), and fall
through to the primary-button click which sends without a note.
note_sent=False is returned so the caller knows the personalization
was dropped.

Verified end-to-end against live LinkedIn: confirmed Premium upsell
appears for an out-of-quota account, dismiss + re-navigate restores
the original gating dialog, primary-button click sends invite.
Profile transitions to 'Pending'. (Tested on babermehkeri and
javargas209.)
@ivangotti

Copy link
Copy Markdown
Author

Found the actual root cause via live-DOM probe.

What was happening: When the free-note monthly quota is exhausted, LinkedIn replaces the 'Add a note?' gating dialog with a 'Send unlimited personalized invites with Premium — You're out of free custom notes' upsell modal. The code waited for the textarea (which never mounts), then dismissed and returned connect_unavailable — discarding the connection request entirely.

The Greptile-flagged :is() and selector-broadening issues both turned out to be diagnosis red herrings. Reverted those (11540ba) and replaced with a targeted upsell-detection + no-note fallback (462ec7b).

Probe-validated end-to-end against live LinkedIn:

  • 'Add a note' click → upsell appears (URL unchanged at /preload/custom-invite/)
  • Detect via :has-text("free custom notes") / "personalized invites with Premium"
  • Dismiss upsell → re-navigate to the same invite URL (re-triggers the original dialog)
  • Click 'Send without a note' → invite sent → profile transitions to Pending

Caller still sees note_sent=False so the dropped personalization is visible.

Verified against babermehkeri and javargas209 — both connections sent successfully via this path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] connect_with_person with note: dialog locator collides with message composer overlay (Emoji button)

1 participant