From 88b2f9a4f1e46c38c8646bcf2a62f67ddf9b7762 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Sat, 3 Oct 2026 21:33:44 -0500 Subject: [PATCH 1/2] fix(agent): drive LinkedIn's SDUI Easy Apply dialog (#403); 1.66.2 LinkedIn moved Easy Apply to its SDUI front end around 2026-09-30. The button still opens the dialog, but it is now a native with generated class names and none of the data-* hooks, so every run waited 15 s for .jobs-easy-apply-modal and timed out. 44 Ready rows were stuck on that. The driver keeps the old selectors and adds the new ones: the dialog holding the easyapply SDUI screen, footer buttons by their text, Dismiss by aria-label, fields by label[for] or aria-label, radio options from the

beside each, errors that appear only after a refused Next, and Follow by its aria-label. The new dialog leaves the mobile number blank, so it is answered from the packet's standard phone. Field reading was checked against the live dialog. New fixture tests copy the live markup. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXYffreKMhXLNgc2wdhsiw --- BACKLOG.md | 3 +- .../BrowserSubmitterTests.cs | 138 ++++++++++++++++++ .../Agent/Browser/LinkedInEasyApply.cs | 69 ++++++++- api/ApplyTrack.Api/ApplyTrack.Api.csproj | 2 +- pyproject.toml | 2 +- src/applytrack/__init__.py | 2 +- uv.lock | 2 +- 7 files changed, 205 insertions(+), 13 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 532de5b..348cd89 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -160,8 +160,9 @@ review items and unsupported forms still need human attention. - [x] [#274 — Ready: a packet whose last run failed is never retried, and a Ready row with no packet is never built — both sit forever](https://github.com/CryptoJones/OSApplyTrack/issues/274) (fixed in 1.48.4: a `ReadyReconciler` on every agent pass queues a prepare for a Ready row with no packet and another dry run for one whose last *dry* run died on something transient — a crash, a timeout, an Apply button that never answered — three failures per seven days, six hours apart. Never for a captcha, a sign-in wall, an unmapped required field, a provider the browser may not drive, a real run, or a submit whose confirmation was not recognised: the board may have taken that one, and a second application is worse than a parked one. New crash evidence carries `transient`, so the rule stops depending on wording) - [x] [#393 — Errors view: a "Retry All" button at the top right](https://github.com/CryptoJones/OSApplyTrack/issues/393) (fixed in 1.66.0: a **Retry all** button on the Errors heading row queues a dry-run retry for every stuck row in one request through the Ready lane's bulk route, which now takes `dry_run: true` to force a dry run whatever *Dry run only* says — so the batch counts once against the submit rate limit and never makes a real click; what was queued and what was not, and why, is announced; focus returns to the heading) - [x] [#394 — Due view: follow up agentically — the agent drafts (and optionally sends) the follow-up](https://github.com/CryptoJones/OSApplyTrack/issues/394) (fixed in 1.66.0: **Follow up** on each Due row and **Follow up on all** walk the follow-ups in one dialog — the tenant's model (any OpenAI-compatible endpoint, metered like any draft, no call at all with drafting off) writes a short email from the application and its dates, the contact, the cover letter, the notes log and the last messages the mailbox has exchanged with that contact; the person edits it, then **Send** mails it from their **own** account (new SMTP host/port on the mailbox settings, same username and sealed app password, host pinned to a public address like the IMAP side) — never the instance's sender — or **Mark followed up** logs one made elsewhere; either way it lands in the notes log and the date moves or clears. No contact on file: the row says so and the draft is still written, to copy. Unreviewed sending split out as #398) -- [ ] [#401 — Mobile: Errors and Due tables overflow or crush on a phone — Retry cut off](https://github.com/CryptoJones/OSApplyTrack/issues/401) +- [x] [#401 — Mobile: Errors and Due tables overflow or crush on a phone — Retry cut off](https://github.com/CryptoJones/OSApplyTrack/issues/401) (fixed in 1.66.1) - [ ] [#398 — Follow-ups: opt-in to let the agent send a drafted follow-up without review](https://github.com/CryptoJones/OSApplyTrack/issues/398) +- [x] [#403 — Agent: LinkedIn Easy Apply times out on the new SDUI dialog (no .jobs-easy-apply-modal)](https://github.com/CryptoJones/OSApplyTrack/issues/403) (fixed in 1.66.2: the driver now also recognizes LinkedIn's SDUI `

`. It finds Next / Review / Submit application and Dismiss by their text or label, reads fields from `label[for]` / `aria-label` and radio options from the text beside each one, catches errors that appear only after a refused Next, unticks Follow by its aria-label, and answers the blank mobile-number box from the packet's standard phone. Checked against the live dialog on 2026-10-03.) ## Production audit — 2026-09-12, closed out 2026-09-13 diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index c72b754..5fd7597 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -74,6 +74,8 @@ public async Task InitializeAsync() _fixture.MapGet("/li/jobs/view/7/", () => Results.Content(EasyApplyHtml.Replace( """Easy Apply""", """Continue

You last modified this application now

"""), "text/html")); + // The same flow in LinkedIn's SDUI dialog, as it shipped around 2026-09-30 (#403). + _fixture.MapGet("/li/jobs/view/sdui", () => Results.Content(EasyApplySduiHtml, "text/html")); // A "Follow" box that will not stay unticked. _fixture.MapGet("/li/jobs/view/sticky-follow", () => Results.Content(EasyApplyHtml.Replace( """id="follow-company-checkbox" checked>""", """id="follow-company-checkbox" checked onchange="this.checked=true">"""), "text/html")); @@ -1904,11 +1906,95 @@ function render() { """; + // The SDUI dialog, copied from the live one on 2026-10-03 (#403): a native around the + // EasyApply screen, generated class names, no data-* hooks; label[for] on contact fields, the + // question as each control's aria-label, radio options in a

beside an empty label, a + // required "*", errors only after a refused Next, Follow ticked on Review, and a second + //

that asks Save or Discard. + private const string EasyApplySduiHtml = """ + +

Senior .NET Engineer

+ + + + + +

Apply to Acme

+
+

+
+

+
+
+

Save this application?

+ +
+ + + """; + private static AgentPacket EasyApplyPacket(bool answered) { var packet = new AgentPacket { ApplicationName = "acme-senior-.net-engineer.md", Provider = ApplyTrack.Api.Agent.AtsProvider.LinkedInEasy }; packet.Questions.Add(new("first_name", "First Name", true, PacketQuestion.Text, [], PacketQuestion.Standard)); packet.Answers["first_name"] = "Ada"; + // The SDUI dialog leaves the mobile number blank (#403); the old one arrives filled. + packet.Questions.Add(new("std:phone", "Phone", false, PacketQuestion.Text, [], PacketQuestion.Standard)); + packet.Answers["std:phone"] = "4025550100"; if (!answered) return packet; // Keyed by label, as the driver hands a discovered question back. foreach (var (label, answer) in new[] @@ -2967,6 +3053,58 @@ public async Task Easy_apply_submits_with_the_packets_answers_and_without_follow Assert.False(sent.GetProperty("follow").GetBoolean()); } + [SkippableFact] + public async Task Easy_apply_sdui_dialog_hands_back_new_questions_and_saves_the_draft() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // LinkedIn's SDUI dialog (#403): same flow, none of the old hooks. Every run timed out + // waiting for .jobs-easy-apply-modal while this dialog sat open on screen. + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui", EasyApplyPacket(answered: false), (Pdf, "resume.pdf"), dryRun: true); + + Assert.False(outcome.Submitted); + Assert.Equal( + ["Are you legally authorized to work in the United States?", "How many years of experience do you have with C#?", "Will you require visa sponsorship?"], + outcome.Unmapped); + var found = Assert.IsType>(outcome.Discovered); + Assert.Equal(["Yes", "No"], found[0].Options); + Assert.Equal(PacketQuestion.Text, found[1].Type); + Assert.Equal(["Yes", "No"], found[2].Options); + Assert.All(found, q => Assert.True(q.Required)); + var left = Assert.Single(EasyApplyEvents()); + Assert.Equal("save", left.GetProperty("left").GetString()); + Assert.Equal(3, left.GetProperty("step").GetInt32()); + } + + [SkippableFact] + public async Task Easy_apply_sdui_dialog_dry_run_reaches_submit_and_discards() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui", EasyApplyPacket(answered: true), (Pdf, "resume.pdf"), dryRun: true); + + Assert.True(outcome.Filled, outcome.Error); + Assert.True(outcome.ReachedSubmit, outcome.Error); + Assert.False(outcome.Submitted); + Assert.Empty(outcome.Unmapped); + Assert.Equal(4, outcome.Mapped.Count); // the phone, and the three questions + Assert.Equal("discard", Assert.Single(EasyApplyEvents()).GetProperty("left").GetString()); + } + + [SkippableFact] + public async Task Easy_apply_sdui_dialog_submits_without_following_the_company() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui", EasyApplyPacket(answered: true), (Pdf, "resume.pdf"), dryRun: false); + + Assert.True(outcome.Submitted, outcome.Error); + Assert.Contains("Application sent", outcome.Confirmation); + var sent = Assert.Single(EasyApplyEvents()).GetProperty("answers"); + Assert.Equal("Yes", sent.GetProperty("q-auth").GetString()); + Assert.Equal("12", sent.GetProperty("q-years").GetString()); + Assert.Equal("No", sent.GetProperty("visa").GetString()); + Assert.Equal("4025550100", sent.GetProperty("_r_t_").GetString()); + Assert.False(sent.GetProperty("follow").GetBoolean()); + } + [SkippableFact] public async Task Easy_apply_says_so_when_linkedin_serves_the_signed_out_page() { diff --git a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs index 739b6e1..e231fcb 100644 --- a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs +++ b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs @@ -50,11 +50,17 @@ internal static partial class LinkedInEasyApply + "a[aria-label^='Easy Apply to' i], button[aria-label^='Easy Apply to' i], " + "a[aria-label^='Continue applying' i], button[aria-label^='Continue applying' i], " + "button#jobs-apply-button-id, button.jobs-apply-button[data-live-test-job-apply-button]"; - private const string Modal = ".jobs-easy-apply-modal"; - private const string Next = Modal + " button[data-easy-apply-next-button], " + Modal + " button[data-live-test-easy-apply-review-button]"; - private const string Submit = Modal + " button[data-live-test-easy-apply-submit-button]"; - private const string Close = Modal + " [data-test-modal-close-btn]"; - private const string Follow = Modal + " #follow-company-checkbox"; + // Two dialogs, one flow. The first is the artdeco modal mapped on 2026-09-20. The second + // shipped around 2026-09-30 with LinkedIn's SDUI front end (#403): a native around + // the EasyApply screen, every class name generated and none of the data-* hooks left, so its + // buttons are known only by what they say. Every run from then on timed out waiting for a + // .jobs-easy-apply-modal that was no longer there, with the dialog already open on screen. + private const string Modal = ":is(.jobs-easy-apply-modal, dialog[open]:has([data-sdui-screen*='easyapply']))"; + private const string Next = Modal + " button[data-easy-apply-next-button], " + Modal + " button[data-live-test-easy-apply-review-button], " + + Modal + " footer button:has-text('Next'), " + Modal + " footer button:has-text('Review')"; + private const string Submit = Modal + " button[data-live-test-easy-apply-submit-button], " + Modal + " button:has-text('Submit application')"; + private const string Close = Modal + " [data-test-modal-close-btn], " + Modal + " button[aria-label='Dismiss']"; + private const string Follow = Modal + " #follow-company-checkbox, " + Modal + " input[type=checkbox][aria-label^='Follow ']"; /// More steps than any real dialog has; a loop that will not end is a bug, not a form. private const int MaxSteps = 12; @@ -309,7 +315,7 @@ void OnResponse(object? _, IResponse r) while (DateTime.UtcNow < deadline) { await page.WaitForTimeoutAsync(1_000); - var said = await page.Locator("[role=dialog], .artdeco-modal").AllInnerTextsAsync(); + var said = await page.Locator("[role=dialog], .artdeco-modal, dialog[open]").AllInnerTextsAsync(); var sent = said.Select(t => Sent().Match(t)).FirstOrDefault(m => m.Success); if (sent is not null || accepted.Task.IsCompletedSuccessfully) { @@ -349,7 +355,8 @@ private static async Task LeaveAsync(IPage page, bool save) try { await page.Locator(Close).First.ClickAsync(new() { Timeout = 5_000 }); - var choice = page.Locator("[data-test-dialog-primary-btn], [data-test-dialog-secondary-btn], [role=alertdialog] button, .artdeco-modal button") + // The SDUI dialog asks "Save this application?" in a second of its own. + var choice = page.Locator("[data-test-dialog-primary-btn], [data-test-dialog-secondary-btn], [role=alertdialog] button, .artdeco-modal button, dialog[open] button") .Filter(new() { HasTextRegex = save ? new Regex(@"^\s*Save\s*$", RegexOptions.IgnoreCase) : new Regex(@"^\s*Discard\s*$", RegexOptions.IgnoreCase) }).First; await choice.ClickAsync(new() { Timeout = 5_000 }); // Let it land. The run ends here and its context closes with it, which would cut off @@ -372,6 +379,10 @@ public static string AnswerFor(AgentPacket packet, string label) // for want of what the résumé already says. The typeahead takes the first match for it. // Only a location that names a city ("Minden, Nebraska"): a bare "United States" would // let the typeahead's first suggestion stand in for a city the résumé never gave. + // The SDUI dialog (#403) leaves the mobile number blank where the old one filled it from + // the profile; the packet's standard phone is the same number. + if (PhoneLabel().IsMatch(label) && packet.Answers.TryGetValue("std:phone", out var phone) && phone.Trim().Length > 0) + return phone.Trim(); if (CityLabel().IsMatch(label) && packet.Resume is { Location.Length: > 0 } resume && AnswerDrafter.StripLocationSuffix(resume.Location) is { } place && place.Contains(',')) return place; @@ -381,6 +392,9 @@ public static string AnswerFor(AgentPacket packet, string label) [GeneratedRegex(@"^\s*(?:city|location|current location|location \(city\))\s*\*?\s*$", RegexOptions.IgnoreCase)] private static partial Regex CityLabel(); + [GeneratedRegex(@"^\s*(?:mobile\s+)?phone(?:\s+number)?\s*\*?\s*$", RegexOptions.IgnoreCase)] + private static partial Regex PhoneLabel(); + private static bool SameLabel(string a, string b) => string.Equals(Squash(a), Squash(b), StringComparison.OrdinalIgnoreCase); @@ -417,7 +431,9 @@ private static async Task FillAsync(IPage page, Field f, string answer) const group = first.closest('fieldset') || first.closest('[data-test-form-element]') || first.parentElement; for (const r of group.querySelectorAll('input[type=radio]')) { const label = first.getRootNode().querySelector(`label[for="${CSS.escape(r.id)}"]`); - if (((label?.innerText || r.value || '').trim().toLowerCase()) === want.toLowerCase()) { (label || r).click(); return r.checked; } + // The SDUI dialog's label is empty; the option's words sit beside it (#403). + const text = (label?.innerText || '').trim() || (r.parentElement?.parentElement?.innerText || '').trim() || r.value || ''; + if (text.trim().toLowerCase() === want.toLowerCase()) { (label || r).click(); return r.checked; } } return false; } @@ -532,6 +548,43 @@ public static async Task> ReadStepAsync(IPage page) || !!g.querySelector('[data-test-form-builder-radio-button-form-component__required], .fb-dash-form-element__label--is-required, [class*=is-required]'); out.push({ Label: text, Kind: kind, Required: required, Filled: filled, Options: options, ControlId: c.id || '', Invalid: !!g.querySelector('.artdeco-inline-feedback--error') }); } + if (groups.length) return JSON.stringify(out); + + // The SDUI dialog (#403): no group wrappers, just controls. A label[for] names a + // contact field; a question's control carries the question as its aria-label, and a + // radio group's options say themselves in a

beside each empty label. "*" marks + // required. Errors arrive only after a refused Next: the helper text's own first

+ // ("Invalid input", "Please enter a valid phone number"), or a

after the fieldset. + const column = modal.querySelector('[data-component-type=LazyColumn]') || modal; + const optionText = r => clean(said(root.querySelector(`label[for="${CSS.escape(r.id)}"]`)) || r.parentElement?.parentElement?.innerText) || r.value; + const errorish = /invalid|required|please (enter|select|make|provide|choose)|must be|enter a (valid|whole|decimal|number)|larger than|less than|between/i; + for (const c of column.querySelectorAll('input:not([type=hidden]):not([type=file]), select, textarea')) { + if (seen.has(c) || !shown(c)) continue; + const kind = c.tagName === 'SELECT' ? 'select' : c.tagName === 'TEXTAREA' ? 'textarea' : c.type === 'radio' ? 'radio' : c.type === 'checkbox' ? 'checkbox' : 'text'; + const aria = clean(c.getAttribute('aria-label')); + if (kind === 'checkbox' && (/top choice/i.test(aria) || /^follow /i.test(aria))) continue; + const fieldset = c.closest('fieldset'); + const radios = kind === 'radio' ? [...(fieldset || column).querySelectorAll(`input[type=radio][name="${CSS.escape(c.name)}"]`)] : []; + radios.forEach(r => seen.add(r)); seen.add(c); + // The résumé cards are a radio group too, each named for its file. + if (kind === 'radio' && (fieldset?.querySelector('[componentkey=easyApplyUploadedResumeRef]') || /\.(pdf|docx?|rtf|txt)$/i.test(aria))) continue; + const box = c.closest('[componentkey^=easyApplyFieldFocus]') || fieldset?.parentElement || c.parentElement?.parentElement || c; + // The question's own

: inside a radio group's box, or just before a text box's. + const lead = box.querySelector(':scope > p') || box.parentElement?.querySelector(':scope > p'); + let text = (kind === 'radio' ? '' : said(root.querySelector(`label[for="${CSS.escape(c.id)}"]`))) || aria || clean(lead?.innerText); + const starred = /\*\s*$/.test(text) || /\*\s*$/.test(clean(lead?.innerText)); + text = text.replace(/\s*\*\s*$/, '').trim(); + if (!text) continue; + const options = kind === 'select' ? [...c.options].map(o => o.text.trim()).filter(t => t && !/^select an option$/i.test(t)) + : radios.map(optionText); + const filled = kind === 'select' ? !!c.value && !/^select an option$/i.test(c.options[c.selectedIndex]?.text.trim() || '') + : kind === 'radio' ? radios.some(r => r.checked) : kind === 'checkbox' ? c.checked : (c.value || '').trim().length > 0; + const required = starred || c.required || c.getAttribute('aria-required') === 'true' || radios.some(r => r.required || r.getAttribute('aria-required') === 'true'); + const helper = box.querySelector('[data-testid=text-input-helper-text] > p'); + const after = fieldset?.nextElementSibling?.tagName === 'P' ? fieldset.nextElementSibling : null; + const invalid = c.getAttribute('aria-invalid') === 'true' || [helper, after].some(p => p && errorish.test(p.innerText || '')); + out.push({ Label: text, Kind: kind, Required: required, Filled: filled, Options: options, ControlId: c.id || '', Invalid: invalid }); + } return JSON.stringify(out); } """); diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index ce9c1b9..5e9db0f 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.66.1 + 1.66.2 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/pyproject.toml b/pyproject.toml index 979a93c..8a6cd04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.66.1" +version = "1.66.2" description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/src/applytrack/__init__.py b/src/applytrack/__init__.py index 129b6f3..fd3a52e 100644 --- a/src/applytrack/__init__.py +++ b/src/applytrack/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """applytrack — a local, markdown-backed job-application tracker.""" -__version__ = "1.66.1" +__version__ = "1.66.2" diff --git a/uv.lock b/uv.lock index dd01f69..3d8b57d 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.66.1" +version = "1.66.2" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 3db9b35ec6a3c6d63690e55325dceb2244af100a Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Sat, 3 Oct 2026 21:51:34 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(agent):=20review=20=E2=80=94=20radio=20?= =?UTF-8?q?fill=20reads=20the=20same=20scope=20and=20first=20line=20ReadSt?= =?UTF-8?q?epAsync=20does;=20SDUI=20submit=20test=20needs=20an=20accepted?= =?UTF-8?q?=20call=20(#403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXYffreKMhXLNgc2wdhsiw --- api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs | 10 +++++++++- api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs | 10 +++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index 5fd7597..de00a15 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -113,6 +113,12 @@ public async Task InitializeAsync() lock (_posts) _posts.Add(new() { ["json"] = body }); return Results.Json(new { ok = true }); }); + // The SDUI dialog's submit call: the fixture reports "sent" only once this has answered 2xx. + _fixture.MapPost("/li/flagship-web/rsc-action/actions/submit", () => + { + lock (_posts) _posts.Add(new() { ["sdui_submit"] = "1" }); + return Results.Json(new { ok = true }); + }); _fixture.MapGet("/jobs/1", () => Results.Content(FormHtml, "text/html")); // Ashby draws its own checkbox and hides the real input: no id, no size, tabindex -1 (#280). _fixture.MapGet("/jobs/styled-checkbox", () => Results.Content(FormHtml.Replace( @@ -1980,7 +1986,8 @@ function render() { function back() { at--; render(); } function ask() { capture(); document.getElementById('ask').show(); } function leave(how) { send({ left: how, step: at }); document.getElementById('ask').close(); dlg.close(); } - async function submitIt() { capture(); await fetch('/li/flagship-web/rsc-action/actions/submit', { method: 'POST' }).catch(() => {}); + async function submitIt() { capture(); const r = await fetch('/li/flagship-web/rsc-action/actions/submit', { method: 'POST' }).catch(() => null); + if (!r || !r.ok) return; await send({ submitted: true, answers }); col.innerHTML = '

Application sent

Your application was sent to Acme.

'; foot.innerHTML = btn('Done', 'close'); } document.getElementById('entry').onclick = () => { at = 0; render(); }; @@ -3103,6 +3110,7 @@ public async Task Easy_apply_sdui_dialog_submits_without_following_the_company() Assert.Equal("No", sent.GetProperty("visa").GetString()); Assert.Equal("4025550100", sent.GetProperty("_r_t_").GetString()); Assert.False(sent.GetProperty("follow").GetBoolean()); + lock (_posts) Assert.Single(_posts, p => p.ContainsKey("sdui_submit")); } [SkippableFact] diff --git a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs index e231fcb..20049df 100644 --- a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs +++ b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs @@ -429,11 +429,15 @@ private static async Task FillAsync(IPage page, Field f, string answer) """ (first, want) => { const group = first.closest('fieldset') || first.closest('[data-test-form-element]') || first.parentElement; - for (const r of group.querySelectorAll('input[type=radio]')) { + // The same scope ReadStepAsync read the options from: every same-name radio. + const scope = first.closest('fieldset') || first.closest('[data-test-form-element]') || first.getRootNode(); + const radios = first.name ? [...scope.querySelectorAll(`input[type=radio][name="${CSS.escape(first.name)}"]`)] : [...group.querySelectorAll('input[type=radio]')]; + const line = t => (t || '').split('\n').map(x => x.trim()).filter(Boolean)[0] || ''; + for (const r of radios) { const label = first.getRootNode().querySelector(`label[for="${CSS.escape(r.id)}"]`); // The SDUI dialog's label is empty; the option's words sit beside it (#403). - const text = (label?.innerText || '').trim() || (r.parentElement?.parentElement?.innerText || '').trim() || r.value || ''; - if (text.trim().toLowerCase() === want.toLowerCase()) { (label || r).click(); return r.checked; } + const text = line(label?.innerText) || line(r.parentElement?.parentElement?.innerText) || r.value || ''; + if (text.toLowerCase() === want.toLowerCase()) { (label || r).click(); return r.checked; } } return false; }