From f59308a4e16c6b0c2e6436b2c4b35ab178fdcc13 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 5 Oct 2026 23:08:48 -0500 Subject: [PATCH 1/5] fix(agent): LinkedIn Easy Apply past the safety reminder, long dialogs, and work-history rows (#430, #409, #408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #430 (KRON, both postings, "Timeout 15000ms exceeded."): pressing Easy Apply on some postings opens LinkedIn's "Job search safety reminder" (PreApplySafetyTipsModal) first. The driver now presses its "Continue applying". When no dialog opens, the failure says what showed instead. #409 (Trility, "did not reach its Submit step within 12 steps"): the dialog is 14 pages, one question each. MaxSteps goes from 12 to 40, and a Next that changes nothing twice in a row now fails with the page it is stuck on. #408 (SMX, "City" unmapped): this was not the contact City. SMX's JobDiva form has a work-history step. LinkedIn lists the profile's roles there, shows "N/A" where the profile has nothing, and each Next opens one short row in an editor with City* and Description* already flagged. Changes: - The driver no longer bails on a field that Next itself flagged and that the run never filled. It answers that field on the next pass of the loop. - Fields in a row editor get labels that name the row ("Work experience — — <company> — City"). - Blank rows that are not yet open are handed back as discovered questions, so a single run returns every row's question. - Description is filled from that role's résumé highlights. City goes to the drafter or the person; it is never filled with today's location. - A textarea's error is read through aria-describedby. - Leaving with Save no longer presses the open row's own "Save". Closes #430 Closes #409 Closes #408 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXYffreKMhXLNgc2wdhsiw --- .../BrowserSubmitterTests.cs | 170 ++++++++++++++++ .../Agent/Browser/LinkedInEasyApply.cs | 191 ++++++++++++++++-- 2 files changed, 344 insertions(+), 17 deletions(-) diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index 76c0447..f1c6209 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -81,6 +81,20 @@ public async Task InitializeAsync() """<button type="button" class="ckymnz ckymny" componentkey="ae6dd0bf" aria-label="Easy Apply to this job" id="entry">Easy Apply</button>""", """<a href="/li/jobs/view/sdui-draft/?trackingId=x" id="entry" class="ckymnz"><span><span>Continue</span></span></a>""") .Replace("document.getElementById('entry').onclick = () => {", "document.getElementById('entry').onclick = e => { e.preventDefault();"), "text/html")); + // LinkedIn's "Job search safety reminder" in front of the dialog (KRON Development, 2026-10-05, #430). + _fixture.MapGet("/li/jobs/view/sdui-safety", () => Results.Content(EasyApplySduiHtml + .Replace("<script>", SafetyTipsDialog + "<script>") + .Replace("document.getElementById('entry').onclick = () => { at = 0; render(); };", + "document.getElementById('entry').onclick = () => document.getElementById('safety').show();"), "text/html")); + // A dialog of fifteen pages, one question each (Trility's is fourteen, #409). + _fixture.MapGet("/li/jobs/view/sdui-long", () => Results.Content(EasyApplySduiHtml + .Replace("const dlg = document.getElementById('dlg')", LongDialogSteps + "const dlg = document.getElementById('dlg')"), "text/html")); + // A work-history step whose blank rows open for editing one Next at a time (SMX via JobDiva, #408). + _fixture.MapGet("/li/jobs/view/sdui-history", () => Results.Content(EasyApplySduiHtml + .Replace("const dlg = document.getElementById('dlg')", WorkHistoryStep + "const dlg = document.getElementById('dlg')") + .Replace("col.innerHTML = steps[at];", "col.innerHTML = steps[at] === 'HISTORY' ? historyHtml() : steps[at];") + .Replace("function go() { if (!valid()) return;", "function go() { if (steps[at] === 'HISTORY' && !historyNext()) return; if (!valid()) return;") + .Replace("function capture() {", "function capture() { answers.history = rows.map(r => r.company + '|' + r.city + '|' + r.desc).join(';');"), "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")); @@ -2021,6 +2035,94 @@ function render() { </body></html> """; + // Copied from the live one (KRON Development, 2026-10-05): a native <dialog> around the + // PreApplySafetyTipsModal screen whose "Continue applying" is a link back to the posting. + private const string SafetyTipsDialog = """ + <dialog id="safety" data-testid="dialog" aria-labelledby="safety-header"> + <button type="button" aria-label="Dismiss" onclick="this.parentElement.close()"><span></span></button> + <div><header id="safety-header"><h2>Job search safety reminder</h2></header> + <div data-testid="dialog-content"><div data-sdui-screen="com.linkedin.sdui.flagshipnav.jobs.PreApplySafetyTipsModal"> + <p>Research the company</p><p>Report suspicious jobs</p> + <footer><a href="/li/jobs/view/sdui-safety?trackingId=x" componentkey="k">Review job post</a> + <a href="/li/jobs/view/sdui-safety?trackingId=x" componentkey="k" onclick="event.preventDefault(); document.getElementById('safety').close(); at = 0; render();"><span><span>Continue applying</span></span></a></footer> + </div></div></div> + </dialog> + """; + + private const string LongDialogSteps = """ + steps.splice(3, 0, ...Array.from({ length: 10 }, (_, i) => `<div><p>Additional Questions</p> + <div><p>Question number ${i}?*</p><div componentkey="easyApplyFieldFocus_x${i}"> + <div><input required id="x${i}" aria-label="Question number ${i}?" type="text" value=""></div> + <div data-testid="text-input-helper-text"></div></div></div></div>`)); + + """; + + // The work-history step as SMX's JobDiva form has it (live, 2026-10-06): the profile's roles as + // cards, "N/A" where the profile holds nothing, and a Next that opens the first short row for + // editing — City* and Description* already flagged, a "Save" of the row's own — instead of + // moving on. The textarea carries the focus key itself, its helper beside it. + private const string WorkHistoryStep = """ + const rows = [ + { title: 'Principal Architect', company: 'Ronin 48', city: 'Omaha, NE', desc: 'Built things.' }, + { title: 'Senior Technical Support Engineer', company: 'CrowdStrike', city: '', desc: '' }, + { title: 'Cloud Operations Supervisor', company: 'CloudHesive', city: '', desc: '' }, + ]; + let editing = -1; + steps.splice(3, 0, 'HISTORY'); + const card = r => `<div><div><div><div><div><p>Your title</p><p>${r.title}</p></div><div><p>Company</p><p>${r.company}</p></div> + <div><p>Dates of employment</p><div><p>8</p><p>/</p><p>2022</p><p> - </p><div><p>7</p><p>/</p><p>2023</p></div></div></div> + <div><p>City</p><p>${r.city || 'N/A'}</p></div><div><p>Description</p><p>${r.desc || 'N/A'}</p></div></div></div> + <button type="button" aria-label="Edit, Work Experience"><span></span></button></div></div>`; + const box = (label, id, value, n) => `<div><p>${label}*</p><div componentkey="easyApplyFieldFocus_ea_validation_p3_9_${n}"> + <div><input required id="${id}" aria-describedby="${id}-info" aria-label="${label}" type="text" value="${value}"></div> + <div data-testid="text-input-helper-text" id="${id}-info">${value ? '' : '<p>This field is required</p>'}</div></div></div>`; + const editor = r => `<div data-display-contents="true"><div><div><p>Edit experience</p><button type="button"><span><span>Save</span></span></button></div><div> + ${box('Your title', 'e-title', r.title, 1)}${box('Company', 'e-company', r.company, 2)} + <div><p>Dates of employment</p><div><p>From</p><div><label for="e-from"><div></div></label><select id="e-from"><option value="">Month</option><option value="8" selected>August</option></select></div></div></div> + ${box('City', 'e-city', '', 3)} + <div><p>Description*</p><div><textarea id="e-desc" aria-describedby="e-desc-info" aria-label="Description" required rows="4" componentkey="easyApplyFieldFocus_ea_validation_p3_9_4"></textarea> + <div data-testid="text-input-helper-text" id="e-desc-info"><p>This field is required</p></div></div></div> + </div><button type="button"><span><span>Delete experience</span></span></button></div></div>`; + function historyHtml() { return `<div><p>Work experience</p>${rows.map((r, i) => i === editing ? editor(r) : card(r)).join('')}<div><p>You have reached the limit of 10 responses</p></div></div>`; } + function historyNext() { + if (editing >= 0) { + const city = col.querySelector('#e-city').value.trim(), desc = col.querySelector('#e-desc').value.trim(); + if (!city || !desc) return false; + rows[editing].city = city; rows[editing].desc = desc; + } + editing = rows.findIndex(r => !r.city || !r.desc); + if (editing < 0) return true; + col.innerHTML = historyHtml(); + return false; + } + + """; + + private static AgentPacket EasyApplyHistoryPacket(bool citiesKnown) + { + var packet = EasyApplyPacket(answered: true); + packet.Resume = new Resume + { + Location = "Minden, Nebraska (Remote)", + Experience = + [ + new("CrowdStrike", "Senior Technical Support Engineer", "Aug 2022 - Mar 2024", ["SME for LogScale.", "Wrote tooling in Python."]), + new("CloudHesive", "Cloud Operations Supervisor / Cloud Support Engineer II", "Aug 2021 - May 2022", ["Led the Cloud Operations team."]), + ], + }; + if (!citiesKnown) return packet; + foreach (var label in new[] + { + "Work experience — Senior Technical Support Engineer — CrowdStrike — City", + "Work experience — Cloud Operations Supervisor — CloudHesive — City", + }) + { + packet.Questions.Add(new(label, label, true, PacketQuestion.Text, [], PacketQuestion.Custom)); + packet.Answers[label] = "Remote"; + } + return packet; + } + private static AgentPacket EasyApplyPacket(bool answered) { var packet = new AgentPacket { ApplicationName = "acme-senior-.net-engineer.md", Provider = ApplyTrack.Api.Agent.AtsProvider.LinkedInEasy }; @@ -3153,6 +3255,74 @@ public async Task Easy_apply_sdui_dialog_submits_without_following_the_company() lock (_posts) Assert.Single(_posts, p => p.ContainsKey("sdui_submit")); } + [SkippableFact] + public async Task Easy_apply_continues_past_linkedins_safety_reminder() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // Live 2026-10-05: both KRON postings failed on "Timeout 15000ms exceeded." with the + // "Job search safety reminder" sitting where the dialog should have been (#430). + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui-safety", EasyApplyPacket(answered: true), (Pdf, "resume.pdf"), dryRun: true); + + Assert.True(outcome.ReachedSubmit, outcome.Error); + Assert.Empty(outcome.Unmapped); + Assert.Equal("discard", Assert.Single(EasyApplyEvents()).GetProperty("left").GetString()); + } + + [SkippableFact] + public async Task Easy_apply_walks_a_dialog_longer_than_twelve_pages() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // Trility's dialog is fourteen pages, one question each; every run ended on page 13 with + // "did not reach its Submit step within 12 steps" (#409). + var packet = EasyApplyPacket(answered: true); + for (var i = 0; i < 10; i++) + { + var label = $"Question number {i}?"; + packet.Questions.Add(new(label, label, true, PacketQuestion.Text, [], PacketQuestion.Custom)); + packet.Answers[label] = $"answer {i}"; + } + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui-long", packet, (Pdf, "resume.pdf"), dryRun: true); + + Assert.True(outcome.ReachedSubmit, outcome.Error); + Assert.Empty(outcome.Unmapped); + Assert.Equal(14, outcome.Mapped.Count); // the phone, three questions, and ten more + } + + [SkippableFact] + public async Task Easy_apply_hands_back_every_short_work_history_row_and_saves_the_draft() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // SMX (#408): the "City" every run stopped on was not the person's — it was a past job's, + // in the work-history editor Next opened. Each blank row is that row's question; the + // résumé has each role's lines for its Description but no city for a job gone by. + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui-history", EasyApplyHistoryPacket(citiesKnown: false), (Pdf, "resume.pdf"), dryRun: true); + + Assert.False(outcome.Submitted); + Assert.Equal(["Work experience — Senior Technical Support Engineer — CrowdStrike — City"], outcome.Unmapped); + var found = Assert.IsType<List<PacketQuestion>>(outcome.Discovered).Select(q => q.Label).ToList(); + Assert.Equal( + ["Work experience — Senior Technical Support Engineer — CrowdStrike — City", "Work experience — Cloud Operations Supervisor — CloudHesive — City"], + found); + Assert.Contains("Work experience — Senior Technical Support Engineer — CrowdStrike — Description", outcome.Mapped); + // Saved through the question's own "Save", not the open row's. + 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_fills_each_work_history_row_as_it_opens_and_submits() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui-history", EasyApplyHistoryPacket(citiesKnown: true), (Pdf, "resume.pdf"), dryRun: false); + + Assert.True(outcome.Submitted, outcome.Error); + var sent = Assert.Single(EasyApplyEvents()).GetProperty("answers"); + Assert.Equal( + "Ronin 48|Omaha, NE|Built things.;CrowdStrike|Remote|SME for LogScale. Wrote tooling in Python.;CloudHesive|Remote|Led the Cloud Operations team.", + sent.GetProperty("history").GetString()); + } + [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 7dd5d12..a683d37 100644 --- a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs +++ b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs @@ -64,11 +64,20 @@ internal static partial class LinkedInEasyApply 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 ']"; - /// <summary>More steps than any real dialog has; a loop that will not end is a bug, not a form.</summary> - private const int MaxSteps = 12; + /// <summary>More steps than any real dialog has; a loop that will not end is a bug, not a form. + /// Twelve was not: Trility's dialog is fourteen pages, one question each, and every run of it + /// ended on page 13 (#409). A work-history step also takes one Next per row it opens for + /// editing (#408). A dialog that is really stuck is caught sooner, by a Next that changes nothing.</summary> + private const int MaxSteps = 40; - /// <summary>One control group in the dialog, as the page describes it.</summary> - public sealed record Field(string Label, string Kind, bool Required, bool Filled, List<string> Options, string ControlId, bool Invalid); + /// <summary>LinkedIn's interstitial before some postings' dialog (KRON Development, 2026-10-05): + /// "Job search safety reminder", whose "Continue applying" link opens the real dialog (#430).</summary> + private const string SafetyTips = "dialog[open]:has([data-sdui-screen*='SafetyTips' i])"; + + /// <summary>One control group in the dialog, as the page describes it. <paramref name="Pending"/>: + /// a blank on a work-history row the step lists but has not opened for editing — no control + /// yet, only a question to have ready for when it opens (#408).</summary> + public sealed record Field(string Label, string Kind, bool Required, bool Filled, List<string> Options, string ControlId, bool Invalid, bool Pending = false); [GeneratedRegex(@"/(login|authwall|checkpoint|uas)(/|\?|$)", RegexOptions.IgnoreCase)] private static partial Regex SignedOut(); @@ -212,27 +221,40 @@ SubmitOutcome Fail(string why, bool closed = false) => return Fail("no Easy Apply button on the posting, and no Apply of any kind: " + Seen(body)); } await entry.ClickAsync(new() { Timeout = 10_000 }); - await page.Locator(Modal).First.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 15_000 }); + if (await OpenDialogAsync(page) is { Length: > 0 } instead) + { + shot = await session.ScreenshotAsync(); + return Fail("the Easy Apply dialog did not open — " + instead); + } + var stuck = 0; for (var step = 1; step <= MaxSteps; step++) { ct.ThrowIfCancellationRequested(); await page.WaitForTimeoutAsync(900); await UntickFollowAsync(page); + var tried = new List<string>(); foreach (var f in await ReadStepAsync(page)) { if (f.Filled && !f.Invalid) continue; var answer = AnswerFor(packet, f.Label); + // A row the step lists but will only open for editing after Next (#408): what + // it is missing is handed back now, so one run learns every row's question + // rather than one per run, and nothing is filled until its editor opens. + if (f.Pending) + { + if (answer.Length == 0 && Unknown(packet, discovered, f.Label)) discovered.Add(AsQuestion(f)); + continue; + } if (answer.Length == 0) { if (!f.Required) continue; unmapped.Add(f.Label); - if (packet.Questions.All(q => !SameLabel(q.Id, f.Label) && !SameLabel(q.Label, f.Label))) - discovered.Add(AsQuestion(f)); + if (Unknown(packet, discovered, f.Label)) discovered.Add(AsQuestion(f)); continue; } - if (await FillAsync(page, f, answer)) mapped.Add(f.Label); + if (await FillAsync(page, f, answer)) { mapped.Add(f.Label); tried.Add(f.Label); } else if (f.Required) unmapped.Add(f.Label); } @@ -270,24 +292,37 @@ SubmitOutcome Fail(string why, bool closed = false) => await LeaveAsync(page, save: true); return Fail($"the Easy Apply dialog offered no way forward at step {step}"); } + var before = await DialogTextAsync(page); await next.ClickAsync(new() { Timeout = 10_000 }); await page.WaitForTimeoutAsync(1_500); // The dialog's own verdict on what was just entered: a field it still flags is one - // the answer did not satisfy (a number wanted, an option not offered). + // the answer did not satisfy (a number wanted, an option not offered). Only an + // answer this step gave counts: a field Next itself brought up — the work-history + // editor that opens on a row LinkedIn holds no city for (#408) — is a new question, + // read and answered on the next pass round the loop like any other. var flagged = (await ReadStepAsync(page)).Where(f => f.Invalid).Select(f => f.Label).ToList(); - if (flagged.Count > 0) + var rejected = flagged.Where(tried.Contains).ToList(); + if (rejected.Count > 0) { - foreach (var label in flagged) { mapped.Remove(label); if (!unmapped.Contains(label)) unmapped.Add(label); } + foreach (var label in rejected) { mapped.Remove(label); if (!unmapped.Contains(label)) unmapped.Add(label); } shot = await session.ScreenshotAsync(); await LeaveAsync(page, save: true); return new SubmitOutcome(true, false, page.Url, "", shot, unmapped, mapped, dryRun ? "" : "refused to submit: the dialog rejected an answer", Discovered: discovered); } + // A Next that changed nothing, twice over, is a dialog going nowhere; say where. + stuck = await DialogTextAsync(page) == before ? stuck + 1 : 0; + if (stuck >= 2) + { + shot = await session.ScreenshotAsync(); + await LeaveAsync(page, save: true); + return Fail($"the Easy Apply dialog would not move past {await PageOfAsync(page)}" + (flagged.Count > 0 ? $" (it flags: {string.Join(", ", flagged)})" : "")); + } } shot = await session.ScreenshotAsync(); await LeaveAsync(page, save: true); - return Fail($"the Easy Apply dialog did not reach its Submit step within {MaxSteps} steps"); + return Fail($"the Easy Apply dialog did not reach its Submit step within {MaxSteps} steps (it stopped at {await PageOfAsync(page)})"); } catch (Exception ex) when (ex is PlaywrightException or TimeoutException) { @@ -334,6 +369,57 @@ void OnResponse(object? _, IResponse r) finally { page.Response -= OnResponse; } } + /// <summary> + /// Wait for the dialog Easy Apply opens, through LinkedIn's safety reminder when it puts one + /// first. Empty once the dialog is up; otherwise what showed instead. Both KRON postings + /// failed on a bare "Timeout 15000ms exceeded." while the reminder sat on screen waiting for + /// "Continue applying" — it is LinkedIn's own, says nothing about the employer, and pressing + /// it is what the person would do (#430). + /// </summary> + private static async Task<string> OpenDialogAsync(IPage page) + { + var visible = new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 15_000 }; + try { await page.Locator($"{Modal}, {SafetyTips}").First.WaitForAsync(visible); } + catch (TimeoutException) { return await WhatShowsAsync(page); } + if (await page.Locator(Modal).First.IsVisibleAsync()) return ""; + await page.Locator($"{SafetyTips} :is(a, button):has-text('Continue applying')").First.ClickAsync(new() { Timeout = 10_000 }); + try { await page.Locator(Modal).First.WaitForAsync(visible); return ""; } + catch (TimeoutException) { return "past LinkedIn's safety reminder, " + await WhatShowsAsync(page); } + } + + private static async Task<string> WhatShowsAsync(IPage page) + { + try + { + var said = (await page.Locator("dialog[open], [role=dialog], [role=alertdialog]").AllInnerTextsAsync()) + .Select(t => t.Trim()).FirstOrDefault(t => t.Length > 0); + return said is null ? "nothing opened when Easy Apply was pressed" : Seen(said).Replace("the page reads", "a dialog reads"); + } + catch (PlaywrightException) { return "nothing opened when Easy Apply was pressed"; } + } + + private static async Task<string> DialogTextAsync(IPage page) + { + try { return await page.Locator(Modal).First.InnerTextAsync(new() { Timeout = 3_000 }); } + catch (Exception ex) when (ex is PlaywrightException or TimeoutException) { return ""; } + } + + /// <summary>"page 13 of 14", from the dialog's own counter, for a failure to say where it was.</summary> + private static async Task<string> PageOfAsync(IPage page) + { + var text = await DialogTextAsync(page); + return PagesRe().Match(text) is { Success: true } m ? $"page {m.Groups[1].Value} of {m.Groups[2].Value}" + : Seen(text).Replace("the page reads", "the step that reads"); + } + + [GeneratedRegex(@"(\d+)\s*/\s*(\d+)\s+pages", RegexOptions.IgnoreCase)] + private static partial Regex PagesRe(); + + /// <summary>A label neither the packet nor this run has met yet.</summary> + private static bool Unknown(AgentPacket packet, List<PacketQuestion> discovered, string label) => + packet.Questions.All(q => !SameLabel(q.Id, label) && !SameLabel(q.Label, label)) + && discovered.All(q => !SameLabel(q.Id, label)); + /// <summary>"Follow <company>" arrives ticked. Applying is not following. True when the /// box is absent or verifiably unticked; false when it could not be shown to be.</summary> private static async Task<bool> UntickFollowAsync(IPage page) @@ -359,8 +445,14 @@ private static async Task LeaveAsync(IPage page, bool save) { await page.Locator(Close).First.ClickAsync(new() { Timeout = 5_000 }); // The SDUI dialog asks "Save this application?" in a second <dialog> 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; + var choices = 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), Visible = true }); + // A work-history row open for editing has a "Save" of its own (#408), earlier in the + // page than the question's; it is not the answer to "Save this application?". + var choice = choices.First; + for (var i = 0; i < await choices.CountAsync(); i++) + if (!await choices.Nth(i).EvaluateAsync<bool>("b => /^\\s*Edit\\b/i.test(b.parentElement?.querySelector(':scope > p')?.innerText || '')")) + { choice = choices.Nth(i); break; } await choice.ClickAsync(new() { Timeout = 5_000 }); // Let it land. The run ends here and its context closes with it, which would cut off // the very request that discards or saves the draft. @@ -378,14 +470,25 @@ public static string AnswerFor(AgentPacket packet, string label) if ((SameLabel(q.Id, label) || SameLabel(q.Label, label)) && packet.Answers.TryGetValue(q.Id, out var a) && a.Trim().Length > 0) return a.Trim(); // The contact step's City / Location typeahead is not in the standard set, and a question - // only reaches the drafter once a run hands it back; SMX's run did not, and sat on "City" - // for want of what the résumé already says. The typeahead takes the first match for it. + // only reaches the drafter once a run hands it back. The typeahead takes the first match + // for what the résumé already says. (SMX's "City", which this was first written for, was + // not this one but a work-history row's — #408, below.) // 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(); + // A work-history row's Description is that role's own lines on the résumé (#408). Its + // City is not: the résumé keeps one location, today's, and a past job's city is a + // question for the drafter or the person, never today's address passed off as then's. + if (HistoryLabel().Match(label) is { Success: true } row && row.Groups["field"].Value.Equals("Description", StringComparison.OrdinalIgnoreCase) + && RoleOf(packet.Resume, row.Groups["company"].Value, row.Groups["title"].Value) is { Highlights.Count: > 0 } role) + { + var lines = string.Join(" ", role.Highlights.Select(h => h.Trim()).Where(h => h.Length > 0)); + return lines.Length > 2_000 ? lines[..2_000].TrimEnd() : lines; + } + if (HistoryLabel().IsMatch(label)) return ""; if (CityLabel().IsMatch(label) && packet.Resume is { Location.Length: > 0 } resume && AnswerDrafter.StripLocationSuffix(resume.Location) is { } place && place.Contains(',')) return place; @@ -395,6 +498,23 @@ 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(); + /// <summary>The label <see cref="ReadStepAsync"/> gives a work-history row's field: + /// "Work experience — <title> — <company> — <field>" (#408).</summary> + [GeneratedRegex(@"^\s*(?<section>Work experience|Education) — (?<title>.+) — (?<company>.+?) — (?<field>[^—]+?)\s*$", RegexOptions.IgnoreCase)] + private static partial Regex HistoryLabel(); + + /// <summary>The résumé's role for a row LinkedIn lists: by company, either name holding the + /// other ("Black Hills Information Security" is "… / Active Countermeasures" on the résumé), + /// and by title too when the company had more than one.</summary> + private static ResumeExperience? RoleOf(Resume? resume, string company, string title) + { + if (resume is null || Squash(company).Length < 2) return null; + bool Holds(string a, string b) => Squash(a).Contains(Squash(b), StringComparison.OrdinalIgnoreCase); + var at = resume.Experience.Where(e => e.Company.Length > 1 && (Holds(e.Company, company) || Holds(company, e.Company))).ToList(); + return at.Count <= 1 ? at.FirstOrDefault() + : at.FirstOrDefault(e => Holds(e.Title, title) || Holds(title, e.Title)) ?? at[0]; + } + [GeneratedRegex(@"^\s*(?:mobile\s+)?phone(?:\s+number)?\s*\*?\s*$", RegexOptions.IgnoreCase)] private static partial Regex PhoneLabel(); @@ -585,17 +705,54 @@ public static async Task<List<Field>> ReadStepAsync(IPage page) const starred = /\*\s*$/.test(text) || /\*\s*$/.test(clean(lead?.innerText)); text = text.replace(/\s*\*\s*$/, '').trim(); if (!text) continue; + // A work-history row open for editing (#408): its "City" is that job's, not the + // person's, so the label says whose — "Work experience — <title> — <company> — City". + const editor = editorOf(c); + if (editor) text = `${editor.section} — ${editor.title} — ${editor.company} — ${text}`; 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'); + // A textarea carries the focus key itself, so its helper is not inside "box"; the + // control names it in aria-describedby either way (#408's Description went unflagged). + const described = c.getAttribute('aria-describedby') ? root.getElementById?.(c.getAttribute('aria-describedby'))?.querySelector(':scope > p') : null; 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 || '')); + const invalid = c.getAttribute('aria-invalid') === 'true' || [helper, described, 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 }); } + + // Work history the employer's form asks for (SMX via JobDiva, #408): LinkedIn lists + // the profile's roles as cards, "N/A" where the profile holds nothing, and opens one + // for editing — City*, Description* — each time Next finds it short. Every blank + // card is a question now, so one run hands all of them back. + for (const edit of column.querySelectorAll("button[aria-label^='Edit, ']")) { + const card = edit.parentElement; + const pairs = [...card.querySelectorAll('p')].filter(p => p.nextElementSibling?.tagName === 'P' && !p.previousElementSibling) + .map(p => [clean(p.innerText), clean(p.nextElementSibling.innerText)]); + if (pairs.length < 2) continue; + const section = sectionOf(edit.getAttribute('aria-label')); + for (const [name, value] of pairs.slice(2)) + if (!value || /^n\/a$/i.test(value)) + out.push({ Label: `${section} — ${pairs[0][1]} — ${pairs[1][1]} — ${name}`, Kind: /description/i.test(name) ? 'textarea' : 'text', + Required: true, Filled: false, Options: [], ControlId: '', Invalid: false, Pending: true }); + } return JSON.stringify(out); + + function sectionOf(s) { return /experience/i.test(s || '') ? 'Work experience' : /education/i.test(s || '') ? 'Education' : clean(s).replace(/^edit,?\s*/i, ''); } + // The row editor: a block whose header line reads "Edit experience" / "Edit education", + // its first two text boxes the row's title and company (school and degree). + function editorOf(c) { + for (let a = c.parentElement, depth = 0; a && a !== modal && depth < 12; a = a.parentElement, depth++) { + const head = a.firstElementChild?.querySelector(':scope > p'); + if (!head || !/^edit (experience|education)\b/i.test(clean(head.innerText))) continue; + const boxes = [...a.querySelectorAll('input[type=text]')]; + if (boxes.length < 2) return null; + return { section: sectionOf(head.innerText), title: (boxes[0].value || '').trim(), company: (boxes[1].value || '').trim() }; + } + return null; + } } """); return JsonSerializer.Deserialize<List<Field>>(json) ?? []; From 946697e69de7ff2025d2b0e93474975ee55d8092 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" <cryptojones@owasp.org> Date: Mon, 5 Oct 2026 23:12:19 -0500 Subject: [PATCH 2/5] =?UTF-8?q?fix(agent):=20long-tail=20sweep=20=E2=80=94?= =?UTF-8?q?=20dropzone=20r=C3=A9sum=C3=A9,=20Easy=20Apply=20fold,=20newsle?= =?UTF-8?q?tter=20box,=20held=20Submit,=20Ashby=20verdict,=20hourly?= =?UTF-8?q?=E2=89=A0url=20(#417-#422)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #417 A file input whose wrapping <label> is only dropzone instructions ("Click or drag & drop PDF") is named by its row's title ("CV / Resume"), in discovery and the required-empty sweep; a stale packet's dropzone-named file question on a one-file-input form is the résumé (Ethos). - #418 The reveal's Apply trigger accepts "Easy Apply" / "Quick Apply" / "One-click apply" (N-iX folds its form behind one; it then meets a reCAPTCHA v2 box and is reported as a captcha). - #419 Newsletter signups (mc4wp, Mailchimp embeds, forms named newsletter) are widgets, not questions (Tecbrains' footer "email"). - #420 Lever's parseResume is uploader chatter; a Submit that sends nothing on a form carrying a captcha widget is a captcha handoff, not "no confirmation" (Lever's invisible hCaptcha never yields a token here). - #421 Board GraphQL responses are read: FormSubmitSuccess is the confirmation, errors[].message is reported, and the post note names the ?op= operation (Ashby). - #422 Drafter: "url" only as a word (it matched "hourly"), and "hourly rate" is a salary question. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXYffreKMhXLNgc2wdhsiw --- .../AnswerDrafterTests.cs | 16 ++ .../BrowserSubmitterTests.cs | 167 ++++++++++++++++++ api/ApplyTrack.Api/Agent/AnswerDrafter.cs | 8 +- .../Agent/Browser/BrowserSession.cs | 29 ++- .../Agent/Browser/BrowserSubmitter.cs | 87 ++++++++- .../Agent/Browser/FormDiscoverer.cs | 9 +- 6 files changed, 301 insertions(+), 15 deletions(-) diff --git a/api/ApplyTrack.Api.Tests/AnswerDrafterTests.cs b/api/ApplyTrack.Api.Tests/AnswerDrafterTests.cs index de7642f..b2c2307 100644 --- a/api/ApplyTrack.Api.Tests/AnswerDrafterTests.cs +++ b/api/ApplyTrack.Api.Tests/AnswerDrafterTests.cs @@ -186,6 +186,22 @@ public async Task A_pinned_name_beats_the_resume_on_every_form_whatever_the_fiel Assert.Equal("Byte", packet.Answers["last_name"]); } + [Fact] + public void An_hourly_rate_question_is_a_salary_question_not_a_website_box() + { + // Wise Integration's Lever card: "url" matched inside "hourly", and the rate went out + // as the LinkedIn link (#422). + var rate = new PacketQuestion("cards[af82][field0]", "Could you please share your expected hourly rate for this role? ✱", true, PacketQuestion.Textarea, [], PacketQuestion.Custom); + var (answer, _) = AnswerDrafter.Deterministic(rate, Ctx()); + Assert.DoesNotContain("http", answer ?? ""); + // A saved yearly figure is converted to an hourly one. + var yearly = Ctx() with { Settings = new AgentSettings { SalaryExpectation = "150000", SalaryPeriod = "annual", SalaryCurrency = "USD" } }; + var (hourly, why) = AnswerDrafter.Deterministic(rate, yearly); + Assert.True(hourly is not null && System.Text.RegularExpressions.Regex.IsMatch(hourly, @"^\d"), $"{hourly} / {why}"); + var other = new PacketQuestion("urls[Other]", "Other URL", false, PacketQuestion.Text, [], PacketQuestion.Custom); + Assert.NotNull(AnswerDrafter.Deterministic(other, Ctx() with { Resume = new Resume { Links = [new("GitHub", "https://github.com/x")] } }).Answer); + } + [Fact] public void Country_and_city_are_deterministic_and_the_standing_country_wins() { diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index 76c0447..f4f4f98 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -754,6 +754,77 @@ function suggest(box) { // A page with no form on it at all. _fixture.MapGet("/jobs/blank", () => Results.Content( "<html><body><h1>Senior Engineer</h1><p>We are hiring. Email us your CV.</p></body></html>", "text/html")); + // Ethos' résumé (#417): the field's title is a sibling <label>, and the hidden input sits in + // a second <label> that says only "Click or drag & drop PDF". No id, no name. + _fixture.MapGet("/jobs/dropzone", () => Results.Content( + """ + <html><body><form method="post" action="/apply" enctype="multipart/form-data"> + <label for="firstName">First name *</label><input id="firstName" name="first_name"> + <label for="email">Email *</label><input id="email" name="email" type="email"> + <div><label>CV / Resume <span>*</span></label> + <label class="dropzone"><span>Click or drag & drop PDF</span><input accept=".pdf" type="file" style="display:none"></label> + <p id="picked"></p></div> + <input type="hidden" name="resume_name" id="resume_name"> + <button type="submit">Submit application</button> + </form> + <script> + document.querySelector('input[type=file]').onchange = e => { const f = e.target.files[0]; + document.getElementById('picked').textContent = f.name; document.getElementById('resume_name').value = f.name; }; + </script></body></html> + """, "text/html")); + // N-iX (#418): the whole form folded in a sidebar until "Easy Apply" is pressed — its file + // input in the page, hidden, from the start. + _fixture.MapGet("/jobs/easy-apply-fold", () => Results.Content( + """ + <html><body><h1>Lead .Net Engineer</h1><p>About the role.</p> + <div id="side" style="display:none"><form method="post" action="/apply" enctype="multipart/form-data"> + <label for="fn">First name</label><input id="fn" name="first_name"> + <label for="ln">Last name</label><input id="ln" name="last_name"> + <label for="em">Email</label><input id="em" name="email" type="email"> + <label for="cv">Click to attach CV</label><input id="cv" name="resume" type="file"> + <input type="submit" value="Apply now"> + </form></div> + <button class="form-toggler" onclick="document.getElementById('side').style.display='block';this.remove()"><span>Easy Apply</span></button> + </body></html> + """, "text/html")); + // Tecbrains (#419): the application form, and a Mailchimp for WordPress signup in the footer. + _fixture.MapGet("/jobs/newsletter-footer", () => Results.Content(FormHtml.Replace("</body>", + """ + <footer><form class="mc4wp-form mc4wp-form-589" method="post"><div class="mc4wp-form-fields"> + <input type="email" name="email" placeholder="Your email address" required><input type="submit" value="Subscribe"> + <label style="display:none !important;">Leave this field empty if you're human: <input type="text" name="_mc4wp_honeypot" value="" tabindex="-1" autocomplete="off"></label> + </div></form></footer></body> + """), "text/html")); + // Lever (#420): Submit only asks an invisible hCaptcha for a token, which never comes, so + // nothing is ever posted. + _fixture.MapGet("/jobs/hcaptcha-held", () => Results.Content( + """ + <html><body><form id="application-form" method="post" action="/apply" enctype="multipart/form-data"> + <label for="first_name">First Name</label><input id="first_name" name="first_name"> + <div id="h-captcha" class="h-captcha" data-sitekey="e33f87f8"></div> + <button id="btn-submit" type="button" onclick="window.executed = true">Submit application</button> + <button id="hcaptchaSubmitBtn" type="submit" style="display:none"></button> + </form></body></html> + """, "text/html")); + // Ashby (#421): the application goes up as a GraphQL mutation answered 200 either way, and + // the page here says nothing — the body is the verdict. + foreach (var path in new[] { "graphql-accepted", "graphql-refused" }) + { + _fixture.MapGet($"/jobs/{path}", () => Results.Content( + $$""" + <html><body> + <label for="first_name">First Name</label><input id="first_name" name="first_name"> + <button type="button" onclick="fetch('/api/non-user-graphql?op=ApiSubmitSingleApplicationFormAction&r={{path}}',{method:'POST',headers:{'content-type':'application/json'},body:'{}'})">Submit Application</button> + </body></html> + """, "text/html")); + } + _fixture.MapPost("/api/non-user-graphql", (HttpRequest req) => + { + lock (_posts) _posts.Add(new() { ["graphql"] = req.Query["op"].ToString() }); + return Results.Content(req.Query["r"] == "graphql-accepted" + ? """{"data":{"submitSingleApplicationFormAction":{"applicationFormResult":{"__typename":"FormSubmitSuccess","_":null},"__typename":"SubmitSingleApplicationFormActionResult"}}}""" + : """{"errors":[{"message":"Missing required field: Location","extensions":{}}],"data":null}""", "application/json"); + }); _fixture.MapPost("/apply", async (HttpRequest req) => { var form = await req.ReadFormAsync(); @@ -4500,6 +4571,102 @@ public async Task A_posting_answering_410_is_reported_gone_not_formless() Assert.Empty(_posts); } + private FormDiscoverer Discoverer() => + new(new BrowserOptions { Endpoint = _ws, AllowPrivateTargets = true, TimeoutSeconds = 60 }, NullLogger<FormDiscoverer>.Instance); + + [SkippableFact] + public async Task A_resume_dropzone_is_named_by_its_field_and_takes_the_resume() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // Ethos: discovered as "Click or drag & drop PDF", never attached, unmapped on every run (#417). + var questions = await Discoverer().DiscoverAsync($"{_fixtureUrl}/jobs/dropzone"); + var file = Assert.Single(questions!, q => q.Type == PacketQuestion.File); + Assert.Equal("CV / Resume", file.Label); + Assert.True(file.Required); + + // A packet built before the fix still carries the dropzone's words; the one file input is + // the résumé all the same. + var stale = new AgentPacket + { + ApplicationName = "ethos-ai-engineer.md", Provider = "unknown", + Questions = + [ + new("firstName", "First name", true, PacketQuestion.Text, [], PacketQuestion.Standard), + new("email", "Email", true, PacketQuestion.Text, [], PacketQuestion.Standard), + new("Click or drag & drop PDF", "Click or drag & drop PDF", true, PacketQuestion.File, [], PacketQuestion.Custom), + ], + Answers = new() { ["firstName"] = "Ada", ["email"] = "ada@example.com" }, + }; + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/dropzone", stale, (Pdf, "resume.pdf"), dryRun: false); + Assert.True(outcome.Submitted, outcome.Error); + Assert.Contains("Click or drag & drop PDF", outcome.Mapped); + Assert.Equal("resume.pdf", Assert.Single(_posts)["resume_name"]); + } + + [SkippableFact] + public async Task A_form_folded_behind_easy_apply_is_opened_and_filled() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // N-iX: "Easy Apply" did not begin with "apply", so only the hidden file input was ever + // seen and first name, last name and email were unmapped (#418). + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/easy-apply-fold", StandardPacket(), (Pdf, "resume.pdf"), dryRun: false); + Assert.True(outcome.Submitted, outcome.Error); + var post = Assert.Single(_posts); + Assert.Equal("Ada", post["first_name"]); + Assert.Equal("ada@example.com", post["email"]); + } + + [SkippableFact] + public async Task A_newsletter_signup_in_the_footer_is_not_a_question() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // Tecbrains: the mc4wp box became the question "email", unmapped on every dry run (#419). + var questions = await Discoverer().DiscoverAsync($"{_fixtureUrl}/jobs/newsletter-footer"); + Assert.NotNull(questions); + Assert.Contains(questions!, q => q.Id == "job_application[email]"); + Assert.DoesNotContain(questions!, q => q.Id is "email" or "_mc4wp_honeypot"); + } + + [SkippableFact] + public async Task A_submit_held_by_an_invisible_captcha_is_a_captcha_handoff() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // Lever: the click asks hCaptcha for a token that never comes; nothing is posted (#420). + var packet = new AgentPacket + { + ApplicationName = "acme-engineer.md", Provider = "lever", + Questions = [new("first_name", "First Name", true, PacketQuestion.Text, [], PacketQuestion.Standard)], + Answers = new() { ["first_name"] = "Ada" }, + }; + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/hcaptcha-held", packet, null, dryRun: false); + Assert.False(outcome.Submitted); + Assert.True(outcome.Captcha, outcome.Error); + Assert.Contains("captcha held it", outcome.Error); + Assert.Empty(_posts); + } + + [SkippableFact] + public async Task Ashby_graphql_submit_answer_is_the_verdict_when_the_page_says_nothing() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // Nuclear Promise X: "no confirmation" with two 200s from non-user-graphql and nothing to + // tell sent from refused (#421). + var packet = new AgentPacket + { + ApplicationName = "acme-engineer.md", Provider = "ashby", + Questions = [new("first_name", "First Name", true, PacketQuestion.Text, [], PacketQuestion.Standard)], + Answers = new() { ["first_name"] = "Ada" }, + }; + var accepted = await Submitter().RunAsync($"{_fixtureUrl}/jobs/graphql-accepted", packet, null, dryRun: false); + Assert.True(accepted.Submitted, accepted.Error); + Assert.Contains("FormSubmitSuccess", accepted.Confirmation); + + var refused = await Submitter().RunAsync($"{_fixtureUrl}/jobs/graphql-refused", packet, null, dryRun: false); + Assert.False(refused.Submitted); + Assert.Contains("(ApiSubmitSingleApplicationFormAction) → 200", refused.Error); + Assert.Contains("the board said \"Missing required field: Location\"", refused.Error); + } + private static string FindRepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/api/ApplyTrack.Api/Agent/AnswerDrafter.cs b/api/ApplyTrack.Api/Agent/AnswerDrafter.cs index 753b9ec..6f52d6f 100644 --- a/api/ApplyTrack.Api/Agent/AnswerDrafter.cs +++ b/api/ApplyTrack.Api/Agent/AnswerDrafter.cs @@ -37,13 +37,17 @@ public sealed partial class AnswerDrafter // deterministic branch at all — it fell through to the model, which answered it from // the brief's saved figure with no units check. Keep this list generous: a salary // question that is NOT recognised skips every guard below. - [GeneratedRegex(@"salary|compensation|remunerat|renumerat|pay (?:expectation|range|rate)|desired pay|rate expectation|expected (?:pay|rate)|day rate", RegexOptions.IgnoreCase)] + // "hourly rate" too: Wise Integration's "expected hourly rate for this role?" matched none + // of these, and fell to the website rule below (#422). + [GeneratedRegex(@"salary|compensation|remunerat|renumerat|pay (?:expectation|range|rate)|desired pay|rate expectation|expected (?:pay|rate)|day rate|hourly (?:rate|pay)|rate per hour", RegexOptions.IgnoreCase)] private static partial Regex Salary(); [GeneratedRegex(@"linkedin", RegexOptions.IgnoreCase)] private static partial Regex LinkedIn(); [GeneratedRegex(@"github", RegexOptions.IgnoreCase)] private static partial Regex GitHub(); - [GeneratedRegex(@"portfolio|website|personal site|url", RegexOptions.IgnoreCase)] + // "URL" as a word: bare, it matched inside "hoURLy" and an hourly-rate question was + // answered with the LinkedIn link (#422). + [GeneratedRegex(@"portfolio|website|personal site|\burls?\b", RegexOptions.IgnoreCase)] private static partial Regex Website(); [GeneratedRegex(@"facebook|twitter|instagram|mastodon|bluesky|threads", RegexOptions.IgnoreCase)] private static partial Regex Social(); diff --git a/api/ApplyTrack.Api/Agent/Browser/BrowserSession.cs b/api/ApplyTrack.Api/Agent/Browser/BrowserSession.cs index 626ba26..e27e47d 100644 --- a/api/ApplyTrack.Api/Agent/Browser/BrowserSession.cs +++ b/api/ApplyTrack.Api/Agent/Browser/BrowserSession.cs @@ -28,7 +28,10 @@ public sealed partial class BrowserSession : IAsyncDisposable // Localised too: join.com renders its posting in the employer's language, so the // real Apply on a French posting is "Postuler maintenant" (#210). - [GeneratedRegex(@"^\s*(?:apply\b|i['’]?m interested|postuler\b|(?:jetzt )?bewerben|aplicar\b|solicitar\b|candidat)", RegexOptions.IgnoreCase)] + // "Easy Apply" and its kin as well: N-iX folds its whole form behind an "Easy Apply" + // button, which "begins with apply" never matched, so the form stayed hidden and only its + // file input was filled (#418). + [GeneratedRegex(@"^\s*(?:(?:easy|quick|one[- ]click)\s+apply\b|apply\b|i['’]?m interested|postuler\b|(?:jetzt )?bewerben|aplicar\b|solicitar\b|candidat)", RegexOptions.IgnoreCase)] private static partial Regex ApplyTrigger(); /// <summary> @@ -61,10 +64,32 @@ public sealed partial class BrowserSession : IAsyncDisposable if (/(?:^|[^a-z])(?:search(?!select)|keyword)|(?:location|job|keyword)search|createnewalert|(?:^|[^a-z])frequency(?:$|[^a-z])|job.?alert|subscribe/.test(own)) return true; return !!el.closest('[role=search], form[action*="search" i], form[class*="search" i], form[id*="search" i], form[name*="search" i], ' + 'form[action*="subscribe" i], form[class*="subscribe" i], form[id*="subscribe" i], form[name*="subscribe" i], ' - + 'form[action*="alert" i], form[class*="alert" i], form[id*="alert" i], [class*="job-alert" i], [class*="jobalert" i], [id*="jobalert" i], [class*="emailsubscribe" i]'); + + 'form[action*="alert" i], form[class*="alert" i], form[id*="alert" i], [class*="job-alert" i], [class*="jobalert" i], [id*="jobalert" i], [class*="emailsubscribe" i], ' + // A newsletter signup in the footer: Tecbrains' Mailchimp for WordPress box + // (<form class="mc4wp-form"><input type=email name=email>) was discovered as the + // application's "email" and left unmapped on every dry run (#419). Mailchimp's own + // embed too, and any form that calls itself a newsletter. + + 'form.mc4wp-form, #mc_embed_signup, form[action*="list-manage.com" i], ' + + 'form[class*="newsletter" i], form[id*="newsletter" i], form[action*="newsletter" i], [class*="newsletter-form" i], [class*="newsletter_form" i]'); } """; + /// <summary> + /// A JavaScript predicate, <c>(text) => bool</c>: is this text a file dropzone's instructions + /// — "Click or drag & drop PDF", "Drop files here", "Browse files" — and nothing that names + /// the document? Ethos wraps its résumé input in such a label, beside the field's real title + /// "CV / Resume", and the question was discovered as "Click or drag & drop PDF" (#417). + /// </summary> + public const string DropzoneJs = """ + (t) => /\b(?:drag\s*(?:&|and|'?n'?|or)?\s*drop|drop\s+(?:your\s+|a\s+|the\s+)?files?\s+here|click\s+(?:here\s+)?to\s+(?:upload|browse|attach|select)|browse\s+(?:for\s+)?(?:a\s+)?files?)/i.test(t) + && !/resume|résumé|\bcv\b|cover|letter|portfolio|transcript|photo|certificat/i.test(t) + """; + + /// <summary>The same test as <see cref="DropzoneJs"/>, for a packet question's label (#417).</summary> + public static bool IsDropzoneText(string text) => + Regex.IsMatch(text, @"\b(?:drag\s*(?:&|and|'?n'?|or)?\s*drop|drop\s+(?:your\s+|a\s+|the\s+)?files?\s+here|click\s+(?:here\s+)?to\s+(?:upload|browse|attach|select)|browse\s+(?:for\s+)?(?:a\s+)?files?)", RegexOptions.IgnoreCase) + && !Regex.IsMatch(text, @"resume|résumé|\bcv\b|cover|letter|portfolio|transcript|photo|certificat", RegexOptions.IgnoreCase); + /// <summary> /// The name of an applicant-tracking system that only takes applications from a /// signed-in candidate account (email + password), for a host Apply led to — or null. diff --git a/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs b/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs index c2a5f0b..69d1109 100644 --- a/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs +++ b/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs @@ -322,14 +322,19 @@ private async Task<SubmitOutcome> RunOnPageAsync( } if (q.Type == PacketQuestion.File) { - if (IsResume(q) && await ResumeOnFileAsync(form)) + // A packet discovered before #417 named Ethos' résumé by its dropzone's + // instructions ("Click or drag & drop PDF"). On a form with one file input, + // a file field named by nothing but those instructions is that input: the résumé. + var isResume = IsResume(q) || (!IsCoverLetter(q) && BrowserSession.IsDropzoneText(q.Label) + && await OnlyFileInputAsync(form) is not null); + if (isResume && await ResumeOnFileAsync(form)) { // The candidate's account already holds a résumé and the form shows it // (SuccessFactors: "Upload Resume · resume.pdf · Edit document", #216). // Nothing to attach; the field is satisfied as it stands. mapped.Add(q.Id); } - else if (IsResume(q) && resumePdf is { } pdf) + else if (isResume && resumePdf is { } pdf) { // With a PDF on hand the résumé is required for the click whatever the // API said: Greenhouse's Job Board API lists GitLab's résumé as optional @@ -507,12 +512,30 @@ private async Task<SubmitOutcome> RunOnPageAsync( var posts = new List<string>(); var captchaRefused = false; var boardSaid = ""; + var boardAccepted = false; page.Response += (_, r) => { var req = r.Request; if (req.Method is "GET" or "HEAD" || IsChatter(r.Url)) return; if (Uri.TryCreate(r.Url, UriKind.Absolute, out var u)) - lock (posts) posts.Add($"{req.Method} {u.Host}{u.AbsolutePath} → {r.Status}"); + lock (posts) posts.Add($"{req.Method} {u.Host}{u.AbsolutePath}{GraphQlOp(u)} → {r.Status}"); + // Ashby answers every GraphQL call 200 and says in the body whether the + // application was taken: applicationFormResult is FormSubmitSuccess, or the form + // again (FormRender) with errors. Two runs ended "no confirmation" with nothing + // to say which (#421). Read the body: success is the confirmation, an error + // message is the board's own words. + if (r.Status is >= 200 and < 300 && u is not null && u.AbsolutePath.Contains("graphql", StringComparison.OrdinalIgnoreCase)) + _ = Task.Run(async () => + { + try + { + var body = await r.TextAsync(); + if (body.Contains("\"FormSubmitSuccess\"", StringComparison.Ordinal)) boardAccepted = true; + var err = Regex.Match(body, "\"errors\"\\s*:\\s*\\[\\s*\\{[^\\]]*?\"message\"\\s*:\\s*\"([^\"]{1,200})\""); + if (err.Success) boardSaid = err.Groups[1].Value; + } + catch (PlaywrightException) { /* body gone with the page */ } + }); // The board's own verdict on the application. Greenhouse answers a submission // its invisible reCAPTCHA Enterprise scored as a bot's with // 428 {"code":"captcha-failed", "security_code_recipient": "<email>"} and asks @@ -555,7 +578,7 @@ async Task WaitForVerdictAsync(bool stopAtCodePrompt) await page.WaitForTimeoutAsync(1000); text = await BodyTextAsync(form); m = Confirmation().Match(text); - if (m.Success) break; + if (m.Success || boardAccepted) break; // "Are you sure you want to apply?" — a board that asks once more before it // sends. Answered once, and only inside a dialog the click opened (#216). if (!confirmedDialog && i >= 1 && await ConfirmDialogAsync(page)) @@ -603,6 +626,9 @@ string PostNote() { lock (posts) return posts.Count == 0 ? " (no application request was sent)" : $" ({string.Join("; ", posts)})"; } + // The page never said so, but the board's own answer did (#421). + if (!m.Success && boardAccepted) + return new SubmitOutcome(true, true, page.Url, "the board answered FormSubmitSuccess" + PostNote(), screenshot, unmapped, mapped, ""); if (!m.Success) { // A challenge thrown up BY the click is the common case, not the rare one: the @@ -639,8 +665,21 @@ string PostNote() if (await FormResetAsync(form, packet, mapped)) return new SubmitOutcome(true, false, page.Url, "", screenshot, unmapped, mapped, "Submit was clicked and the form reset itself with no confirmation" + PostNote() + " — check the screenshot"); + // The click sent nothing at all, and the form carries a captcha — the invisible + // kind included. Lever's Submit only asks hCaptcha for a token and posts the form + // from its callback; from this browser hCaptcha never answers, so the click is + // swallowed and nothing leaves. Both Lever runs read "no confirmation" for an + // application that was never posted (#420). That is a captcha holding the form, + // the person's to finish, not a mystery to retry. + bool noneSent; + lock (posts) noneSent = posts.Count == 0; + if (noneSent && page.Url == urlBefore && (await HasCaptchaWidgetAsync(form) || await HasCaptchaWidgetAsync(page.MainFrame))) + return new SubmitOutcome(true, false, page.Url, "", screenshot, unmapped, mapped, + "Submit sent nothing — the form's captcha held it (no application request was sent); finish it with Copy answers and open", + Captcha: true); return new SubmitOutcome(true, false, page.Url, "", screenshot, unmapped, mapped, - "Submit was clicked but no confirmation text was recognised" + PostNote() + " — check the screenshot"); + "Submit was clicked but no confirmation text was recognised" + PostNote() + + (boardSaid.Length > 0 ? $" — the board said \"{boardSaid}\"" : "") + " — check the screenshot"); } var start = Math.Max(0, m.Index - 80); var snippet = text.Substring(start, Math.Min(text.Length - start, 240)).Trim(); @@ -1017,7 +1056,9 @@ private static async Task<bool> CommittedAsync(ILocator control, string chosen) const by = el.getAttribute('aria-labelledby'); if (by) { const t = by.split(/\s+/).map(i => document.getElementById(i)?.innerText || '').join(' ').trim(); if (t) return t; } if (el.id) { const l = document.querySelector(`label[for="${CSS.escape(el.id)}"]`); if (l) return l.innerText; } - const wrap = el.closest('label'); if (wrap) return wrap.innerText; + // A dropzone's instructions are not the field's name; the row's title is (#417). + const wrap = el.closest('label'); + if (wrap && !(el.type === 'file' && (__DROPZONE__)(wrap.innerText || ''))) return wrap.innerText; const legend = el.closest('fieldset')?.querySelector('legend'); if (legend) return legend.innerText; // The row's own label when the page never tied it to the control — the same rule // discovery names the field by, so the sweep and the packet agree on it. SuccessFactors' @@ -1188,7 +1229,7 @@ private static async Task<bool> CommittedAsync(ILocator control, string chosen) } return out; } - """.Replace("__WIDGET__", BrowserSession.WidgetJs); + """.Replace("__WIDGET__", BrowserSession.WidgetJs).Replace("__DROPZONE__", BrowserSession.DropzoneJs); /// <summary>Greenhouse's security-code prompt: one box per character, ids <c>security-input-N</c>, /// under "A verification code was sent to …".</summary> @@ -2580,6 +2621,32 @@ private static async Task<bool> ResumeTookAsync(IFrame page, string fileName) /// submission, so anything inside <c>.grecaptcha-badge</c> must NOT count — only a challenge /// a person has to touch (the v2 checkbox, hCaptcha, Turnstile). /// </summary> + /// <summary> + /// A captcha widget anywhere in the form, shown or not: hCaptcha's container or frames, a + /// reCAPTCHA (v2 box or the invisible kind), Turnstile. Not a reason to stop on its own — + /// most boards carry one and post regardless — only the explanation for a Submit that sent + /// nothing (#420). + /// </summary> + private static async Task<bool> HasCaptchaWidgetAsync(IFrame page) + { + try + { + return await page.EvaluateAsync<bool>(""" + () => !!document.querySelector('.h-captcha, [data-hcaptcha-widget-id], iframe[src*="hcaptcha.com"], ' + + '.g-recaptcha, .grecaptcha-badge, iframe[src*="recaptcha"], .cf-turnstile, iframe[src*="challenges.cloudflare.com"]') + """); + } + catch (PlaywrightException) { return false; } + } + + /// <summary>The GraphQL operation a POST named in its query string — Ashby's + /// <c>/api/non-user-graphql?op=…</c> — as " (op)", or "" (#421).</summary> + private static string GraphQlOp(Uri u) + { + var op = Regex.Match(u.Query, @"[?&]op=([A-Za-z0-9_]{1,80})"); + return op.Success ? $" ({op.Groups[1].Value})" : ""; + } + private static async Task<bool> HasCaptchaAsync(IFrame page) { try @@ -3042,9 +3109,11 @@ private static bool IsCoverLetter(PacketQuestion q) => private static partial Regex AlreadyApplied(); /// <summary>Requests that are never the application: analytics beacons, the captcha's own - /// traffic, and the résumé uploader's trip to object storage.</summary> + /// traffic, the résumé uploader's trip to object storage, and Lever's résumé parser — + /// <c>POST /parseResume</c> stood in two "no confirmation" verdicts for an application that + /// was never posted (#420).</summary> private static bool IsChatter(string url) => - Regex.IsMatch(url, @"snowplow|analytics|recaptcha|hcaptcha|gstatic|googleapis|amazonaws\.com|sentry|segment\.io|mixpanel|datadog", RegexOptions.IgnoreCase); + Regex.IsMatch(url, @"snowplow|analytics|recaptcha|hcaptcha|gstatic|googleapis|amazonaws\.com|sentry|segment\.io|mixpanel|datadog|/parseResume\b", RegexOptions.IgnoreCase); /// <summary>The page's visible text, or "" if it can't be read — never throws.</summary> private static async Task<string> BodyTextAsync(IFrame page) diff --git a/api/ApplyTrack.Api/Agent/Browser/FormDiscoverer.cs b/api/ApplyTrack.Api/Agent/Browser/FormDiscoverer.cs index 1c57331..35c7761 100644 --- a/api/ApplyTrack.Api/Agent/Browser/FormDiscoverer.cs +++ b/api/ApplyTrack.Api/Agent/Browser/FormDiscoverer.cs @@ -157,7 +157,12 @@ private static List<PacketQuestion> Map(List<Control> controls) const by = el.getAttribute('aria-labelledby'); if (by) { const t = by.split(/\s+/).map(i => document.getElementById(i)?.innerText || '').join(' ').trim(); if (t) return t; } if (el.id) { const l = document.querySelector(`label[for="${CSS.escape(el.id)}"]`); if (l) return l.innerText; } - const wrap = el.closest('label'); if (wrap) return wrap.innerText; + // A dropzone's own label is its instructions, not the field: Ethos wraps its hidden + // résumé input in <label>Click or drag & drop PDF</label> under a sibling + // <label>CV / Resume *</label>, and the résumé went unrecognised (#417). The row's + // title, found below, names it. + const wrap = el.closest('label'); + if (wrap && !(el.type === 'file' && (__DROPZONE__)(wrap.innerText || ''))) return wrap.innerText; const legend = el.closest('fieldset')?.querySelector('legend'); if (legend) return legend.innerText; // A label the page never associated with its control. Zoho Recruit's form is // web components: the input has no id, no <label for>, no aria — but the @@ -308,5 +313,5 @@ private static List<PacketQuestion> Map(List<Control> controls) } return out; } - """.Replace("__WIDGET__", BrowserSession.WidgetJs); + """.Replace("__WIDGET__", BrowserSession.WidgetJs).Replace("__DROPZONE__", BrowserSession.DropzoneJs); } From a3e61e8b7d83369dc358d8c5b75425563fd62ea3 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" <cryptojones@owasp.org> Date: Mon, 5 Oct 2026 23:29:09 -0500 Subject: [PATCH 3/5] fix(agent): find the way in and the way out on no-form / no-Submit postings (#423 #424 #425 #426 #427 #428 #429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #423 Apply trigger: "candidat" no longer matches English "Candidate …" (Ford's "Candidate FEEDBACK" tab opened a survey instead of Apply); Spanish "Postular" added. - #424 Submit that is a script's anchor inside the form (<a href="#">APPLY NOW</a>, JazzHR "SUBMIT APPLICATION") is found; ButtonsSeen lists form anchors. - #425 The required-empty sweep honours a starred label, as discovery does, and a select still on a placeholder that carries a value ("-- Select") is empty, not prefilled; discovery drops that placeholder from the options (HubSpot). - #426 Apply is looked for in embedded frames too (Voleon's Ashby iframe); a new tab's first navigation request no longer throws in the route handler and hangs the tab (7seventy's target=_blank, rel=noopener Apply); one more Apply is pressed on the page Apply led to; ad networks are aborted (AdSense vignette). - #427 "this job has expired", "no longer accepting new profiles", "taken the job offline" read as closed (SmartRecruiters, Ceipal, MeeBoss). - #428 A bot-protection block page (Cloudflare 403) is "the site refused this server" at any length (remotehunter). - #429 An email-only page mid-wizard (ClearCompany's returning-applicant sign-in, a plain text box) is a sign-in: Next is not pressed on it and the run names it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXYffreKMhXLNgc2wdhsiw --- .../BrowserSubmitterTests.cs | 218 ++++++++++++++++++ .../Agent/Browser/BrowserSession.cs | 170 ++++++++++---- .../Agent/Browser/BrowserSubmitter.cs | 84 ++++++- .../Agent/Browser/FormDiscoverer.cs | 6 +- 4 files changed, 429 insertions(+), 49 deletions(-) diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index 76c0447..a8f28dc 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -754,6 +754,86 @@ function suggest(box) { // A page with no form on it at all. _fixture.MapGet("/jobs/blank", () => Results.Content( "<html><body><h1>Senior Engineer</h1><p>We are hiring. Email us your CV.</p></body></html>", "text/html")); + // Tential / JazzHR: the form's Submit is a script's anchor, not a button (#424). + _fixture.MapGet("/jobs/anchor-submit", () => Results.Content( + """ + <html><body><h1>.NET Developer</h1> + <a href="#" onclick="return false">APPLY NOW</a> + <form id="ce_apply_form" method="post" action="/apply"> + <label for="first_name">First Name<span>*</span></label><input id="first_name" name="job_application[first_name]" required> + <p><a href="/privacy">Privacy Policy</a></p> + <div class="ce_apply_submit_wrapper"><a href="#" id="ce_apply_submit" onclick="document.getElementById('ce_apply_form').submit();return false"><span>APPLY NOW</span></a></div> + </form> + </body></html> + """, "text/html")); + // HubSpot's rendering of its Greenhouse form: required only by the star in the label, a + // placeholder option that carries a value, and Submit disabled until all are answered (#425). + _fixture.MapGet("/jobs/starred-selects", () => Results.Content( + """ + <html><body><h1>Principal Engineer</h1> + <form method="post" action="/apply"> + <label for="first_name">First Name<span>*</span></label><input id="first_name" name="job_application[first_name]"> + <label for="question_45">Do you currently have legal US work authorization? <span>*</span></label> + <select id="question_45" name="question_45"><option value="-- Select">-- Select</option><option>Yes</option><option>No</option></select> + <label for="question_54">Which time zone would you be working from in this role? <span>*</span></label> + <select id="question_54" name="question_54"><option value="-- Select">-- Select</option><option>Eastern</option><option>Central</option></select> + <label for="question_53">Have you worked here before?</label> + <select id="question_53" name="question_53"><option value="-- Select">-- Select</option><option>Yes</option><option>No</option></select> + <button type="submit" disabled>Submit Your Application</button> + </form> + <script> + const starred = ['question_45', 'question_54'].map(id => document.getElementById(id)); + const settle = () => document.querySelector('button[type=submit]').disabled = + !document.getElementById('first_name').value || starred.some(s => s.value === '-- Select'); + document.addEventListener('input', settle); document.addEventListener('change', settle); + </script> + </body></html> + """, "text/html")); + // Voleon: the posting (and its Apply) is an embedded ATS page in an iframe (#426). + _fixture.MapGet("/jobs/embedded-posting", () => Results.Content( + """<html><body><h1>Voleon - Job Listings</h1><iframe src="/jobs/embedded-posting-inner" style="width:900px;height:1200px"></iframe></body></html>""", "text/html")); + _fixture.MapGet("/jobs/embedded-posting-inner", () => Results.Content( + """<html><body><h2>Senior Software Engineer</h2><p>About the role.</p><a href="/jobs/1">Apply for this Job</a></body></html>""", "text/html")); + // 7seventy: an aggregator whose Apply opens the employer's posting in a new tab (target=_blank, + // rel=noopener), and that posting has its own Apply (#426). + _fixture.MapGet("/jobs/aggregator", () => Results.Content( + """<html><body><h1>Staff Backend Engineer</h1><p>Remote.</p><a href="/jobs/ats-posting" target="_blank" rel="nofollow noopener noreferrer" class="apply-btn">Apply for This Job</a></body></html>""", "text/html")); + _fixture.MapGet("/jobs/ats-posting", () => Results.Content( + """<html><body><h1>Staff Backend Engineer @ Astra</h1><p>Overview.</p><button onclick="location.href='/jobs/1'">Apply for this Job</button></body></html>""", "text/html")); + // Ford: a "Candidate FEEDBACK" tab before the real Apply Now; pressing it opens a survey (#423). + _fixture.MapGet("/jobs/candidate-tab", () => Results.Content( + """ + <html><body> + <button style="position:fixed;right:0;top:300px" onclick="document.getElementById('survey').style.display='block'">Candidate FEEDBACK</button> + <div id="survey" role="dialog" style="display:none;position:fixed;inset:0;background:#fff"><h2>Candidate Experience Survey</h2><button>Start Survey</button><button>Not Now</button></div> + <h1>Enterprise AI Administrator</h1> + <a href="/jobs/1" class="job-apply">Apply Now</a> + </body></html> + """, "text/html")); + // Cloudflare's block page: HTTP 403 and more than 600 characters of explanation (#428). + _fixture.MapGet("/jobs/cloudflare-block", () => Results.Content( + "<html><head><title>Attention Required! | Cloudflare

Sorry, you have been blocked

You are unable to access example.com

" + + "

" + string.Join(" ", Enumerable.Repeat("This website is using a security service to protect itself from online attacks.", 10)) + "

", + "text/html", statusCode: 403)); + // ClearCompany's returning applicant: page one of the wizard, Continue, and then an email + // sign-in — a plain text box and NEXT — in place of page two (#429). + _fixture.MapGet("/jobs/returning-applicant", () => Results.Content( + """ +

.NET Software Engineer Application

+ + + +
+ + + """, "text/html")); _fixture.MapPost("/apply", async (HttpRequest req) => { var form = await req.ReadFormAsync(); @@ -4500,6 +4580,144 @@ public async Task A_posting_answering_410_is_reported_gone_not_formless() Assert.Empty(_posts); } + private static AgentPacket OnePacket(params (string Id, string Label, string Answer)[] fields) => new() + { + ApplicationName = "acme-engineer.md", Provider = "unknown", + Questions = [.. fields.Select(f => new PacketQuestion(f.Id, f.Label, true, PacketQuestion.Text, [], PacketQuestion.Standard))], + Answers = fields.ToDictionary(f => f.Id, f => f.Answer), + }; + + [SkippableFact] + public async Task A_submit_that_is_a_scripts_anchor_inside_the_form_is_clicked() + { + // Tential: APPLY NOW ends the form, and every run + // filled it and said "no Submit button found" (#424). The posting's own APPLY NOW, outside + // the form, is never the one clicked. + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/anchor-submit", + OnePacket(("first_name", "First Name", "Ada")), null, dryRun: false); + Assert.True(outcome.Submitted, outcome.Error); + Assert.Equal("Ada", Assert.Single(_posts)["job_application[first_name]"]); + } + + [SkippableFact] + public async Task A_select_required_only_by_the_star_in_its_label_and_left_on_its_placeholder_is_handed_to_the_drafter() + { + // HubSpot: a dozen selects marked "… *" and nothing else, each on "-- Select" (a placeholder + // with a value). The sweep let them through, Submit stayed disabled, "no Submit button + // found" (#425). Now they are unmapped and go to the drafter, placeholder not an option. + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var dry = await Submitter().RunAsync($"{_fixtureUrl}/jobs/starred-selects", + OnePacket(("first_name", "First Name", "Ada")), null, dryRun: true); + Assert.Contains("question_45", dry.Unmapped); + Assert.Contains("question_54", dry.Unmapped); + Assert.DoesNotContain("question_53", dry.Unmapped); // no star: not required + Assert.NotNull(dry.Discovered); + var auth = Assert.Single(dry.Discovered!, q => q.Id == "question_45"); + Assert.True(auth.Required); + Assert.Equal(["Yes", "No"], auth.Options); + + var real = await Submitter().RunAsync($"{_fixtureUrl}/jobs/starred-selects", + OnePacket(("first_name", "First Name", "Ada")), null, dryRun: false); + Assert.False(real.Submitted); + Assert.StartsWith("refused to submit: required fields could not be mapped", real.Error); + Assert.Empty(_posts); + + // Drafted, the next run fills them — a "-- Select" with a value is not taken for an + // answer the board already holds — and Submit, enabled by then, goes. + var drafted = OnePacket(("first_name", "First Name", "Ada")); + drafted.Questions.AddRange(dry.Discovered!); + drafted.Answers["question_45"] = "Yes"; + drafted.Answers["question_54"] = "Central"; + var done = await Submitter().RunAsync($"{_fixtureUrl}/jobs/starred-selects", drafted, null, dryRun: false); + Assert.True(done.Submitted, done.Error); + var post = Assert.Single(_posts); + Assert.Equal("Yes", post["question_45"]); + Assert.Equal("Central", post["question_54"]); + } + + [SkippableFact] + public async Task An_apply_inside_an_embedded_posting_frame_is_found_and_pressed() + { + // Voleon embeds its Ashby posting in an iframe; the Apply in it was never looked for, and + // the run said "no Apply button or link on the page" (#426). + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/embedded-posting", Packet(), (Pdf, "resume.pdf"), dryRun: true); + Assert.True(outcome.Filled, outcome.Error); + Assert.Contains("first_name", outcome.Mapped); + Assert.Empty(_posts); + } + + [SkippableFact] + public async Task An_apply_that_opens_another_posting_in_a_noopener_tab_is_followed_through_its_own_apply() + { + // 7seventy: Apply opens the employer's posting in a new tab (rel=noopener). The new tab's + // first request had no frame yet, asking for one threw in the route handler, and the tab + // hung; and once it loads, that posting's own Apply has to be pressed too (#426). + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/aggregator", Packet(), (Pdf, "resume.pdf"), dryRun: true); + Assert.True(outcome.Filled, outcome.Error); + Assert.Contains("first_name", outcome.Mapped); + Assert.EndsWith("/jobs/1", outcome.Url); + Assert.Empty(_posts); + } + + [Theory] + [InlineData("Apply Now", true)] + [InlineData("Postuler maintenant", true)] + [InlineData("Postular", true)] + [InlineData("Candidater", true)] + [InlineData("Candidatar-se", true)] + [InlineData("Candidate FEEDBACK", false)] + [InlineData("Candidate Experience", false)] + [InlineData("Candidates", false)] + [InlineData("Apply later", false)] + public void The_apply_trigger_is_a_word_for_applying_not_the_english_word_candidate(string name, bool trigger) => + // Ford's "Candidate FEEDBACK" tab was pressed as Apply and opened a survey (#423). + Assert.Equal(trigger, BrowserSession.IsApplyTrigger(name)); + + [SkippableFact] + public async Task A_candidate_feedback_tab_is_never_pressed_for_apply() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/candidate-tab", Packet(), (Pdf, "resume.pdf"), dryRun: true); + Assert.True(outcome.Filled, outcome.Error); + Assert.Contains("first_name", outcome.Mapped); + } + + [Theory] + [InlineData("Principal AI Engineer This job has expired Sorry, this job has expired SHARE THIS JOB")] // SmartRecruiters + [InlineData("C# Developer | $65/hr Remote We are no longer accepting new profiles for this position.")] // Ceipal + [InlineData("The employer has taken the job offline. The operation failed. Try again")] // MeeBoss + public void Expired_and_offline_postings_read_as_closed(string text) => + // Each read as "no Apply button or link on the page" (#427). + Assert.True(BrowserSubmitter.IsClosedPosting(text)); + + [SkippableFact] + public async Task A_bot_protection_block_page_is_the_site_refusing_this_server_at_any_length() + { + // remotehunter.com: Cloudflare's 403 page is over 600 characters, and read as "no Apply + // button or link on the page" (#428). + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/cloudflare-block", Packet(), (Pdf, "resume.pdf"), dryRun: true); + Assert.Contains("refused this server (HTTP 403)", outcome.Error); + Assert.False(outcome.Closed); + } + + [SkippableFact] + public async Task An_email_sign_in_met_mid_wizard_is_named_and_its_next_never_pressed() + { + // ClearCompany: after page one, a returning applicant gets "Enter the email you used to + // apply" (a plain text box) and NEXT. The walker took it for a form page, pressed NEXT on it + // empty, and the run ended "no Submit button found — NEXT, close" (#429). + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/returning-applicant", + OnePacket(("first_name", "First Name", "Ada"), ("email", "Email", "ada@example.com")), null, dryRun: false); + Assert.False(outcome.Submitted); + Assert.StartsWith("this page is an email sign-in", outcome.Error); + Assert.Empty(_posts); + } + private static string FindRepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/api/ApplyTrack.Api/Agent/Browser/BrowserSession.cs b/api/ApplyTrack.Api/Agent/Browser/BrowserSession.cs index 626ba26..02be7c9 100644 --- a/api/ApplyTrack.Api/Agent/Browser/BrowserSession.cs +++ b/api/ApplyTrack.Api/Agent/Browser/BrowserSession.cs @@ -27,10 +27,17 @@ public sealed partial class BrowserSession : IAsyncDisposable ]; // Localised too: join.com renders its posting in the employer's language, so the - // real Apply on a French posting is "Postuler maintenant" (#210). - [GeneratedRegex(@"^\s*(?:apply\b|i['’]?m interested|postuler\b|(?:jetzt )?bewerben|aplicar\b|solicitar\b|candidat)", RegexOptions.IgnoreCase)] + // real Apply on a French posting is "Postuler maintenant" (#210). Spanish boards say + // "Postular" (laborum.news). "candidat" is the verb or noun of the Romance languages — + // candidater, candidatar, candidatura — never the English word: Ford's careers page has + // a "Candidate FEEDBACK" tab, the reveal pressed it as Apply, and a survey opened where + // the form should have (#423). + [GeneratedRegex(@"^\s*(?:apply\b|i['’]?m interested|postuler\b|postular\b|(?:jetzt )?bewerben|aplicar\b|solicitar\b|candidat(?!es?\b))", RegexOptions.IgnoreCase)] private static partial Regex ApplyTrigger(); + /// Does a control's name read as the way into an application? Public for tests. + public static bool IsApplyTrigger(string name) => ApplyTrigger().IsMatch(name) && !LaterWords().IsMatch(name); + /// /// A control that only LOOKS like the way in: "Apply later", "Send me the link", /// "Remind me", a job-alert subscription. join.com puts an email box and an @@ -209,6 +216,15 @@ await context.RouteAsync("**/*", async route => await route.AbortAsync(); return; } + // Display advertising is never part of an application, and Google's AdSense "vignette" + // is worse than noise: a full-screen ad that 7seventy's job aggregator raises over the + // page when its Apply link is clicked (#google_vignette), so the click opened an ad, + // not the posting, and the run reported "no form appeared" (#426). + if (IsAdNetwork(u.Host)) + { + await route.AbortAsync(); + return; + } // The live-form verification harness: nothing that could create an application // leaves. The one exception is the board's résumé uploader, which stages the file // straight to object storage with a multipart POST — an application is always @@ -222,7 +238,7 @@ await context.RouteAsync("**/*", async route => // that hosts its form are fine); sub-resources are left alone so the form // can load its scripts and styles. Any top-level frame in the context — the // tab an Apply link opens is held to the same rule as the first page. - if (req.IsNavigationRequest && req.Frame.ParentFrame is null && !HostAllowed(u.Host, allowedHost) + if (req.IsNavigationRequest && IsTopLevel(req) && !HostAllowed(u.Host, allowedHost) && BoardAccount.For(session._accounts, u.Host) is null) { lock (session._refused) { session._refused.Add(u.Host); session._refusedUrls.Add(u.AbsoluteUri); } @@ -421,6 +437,28 @@ private static bool IsStorageUpload(Uri u, IRequest req) => && req.Headers.TryGetValue("content-type", out var ct) && ct.StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase); + /// + /// Is a navigation request a top-level page's? The first request of a new tab — the one a + /// target="_blank" Apply opens — is issued before its frame exists, and asking for that frame + /// throws ("Frame for this navigation request is not available"). It threw inside the route + /// handler, the request was never continued, and the tab hung on about:blank: 7seventy's + /// "Apply for This Job" (rel=noopener, to Ashby) opened nothing the run could see (#426). A + /// request with no frame yet is a new tab's, which is top-level. + /// + private static bool IsTopLevel(IRequest req) + { + try { return req.Frame.ParentFrame is null; } + catch (PlaywrightException) { return true; } + } + + /// An ad network's host — Google's AdSense and DoubleClick (#426). + private static bool IsAdNetwork(string host) + { + host = host.ToLowerInvariant(); + return host.EndsWith("googlesyndication.com") || host.EndsWith("doubleclick.net") + || host.EndsWith("googleadservices.com") || host.StartsWith("adservice.google."); + } + public static bool HostAllowed(string host, string original) { host = host.ToLowerInvariant(); @@ -597,30 +635,7 @@ public async Task RevealFormAsync() await KeepPostingTextAsync(); return; } - // By accessible name first, then by what the control visibly says. Allstate labels its - // link aria-label="Lead .Net Software Engineer Apply Now open in new window": the name - // opens with the job title, so nothing "began with apply" and the run reported "no Apply - // button or link on the page" with "Apply now" in plain view (#280). And the first one - // that can be SEEN, not the first in the document — a collapsed panel's own "Apply" - // must not stand in for the posting's. - ILocator? apply = null; - foreach (var matches in new[] - { - Page.GetByRole(AriaRole.Button, new() { NameRegex = ApplyTrigger() }).Filter(new() { HasNotTextRegex = LaterWords() }), - Page.GetByRole(AriaRole.Link, new() { NameRegex = ApplyTrigger() }).Filter(new() { HasNotTextRegex = LaterWords() }), - Page.Locator("a[href], button, [role=button], input[type=submit], input[type=button]") - .Filter(new() { HasTextRegex = ApplyTrigger() }).Filter(new() { HasNotTextRegex = LaterWords() }), - }) - { - try - { - var count = Math.Min(await matches.CountAsync(), 8); - for (var i = 0; i < count && apply is null; i++) - if (await matches.Nth(i).IsVisibleAsync()) apply = matches.Nth(i); - } - catch (PlaywrightException) { /* next */ } - if (apply is not null) break; - } + var apply = await FindApplyAsync(); if (apply is null) { // A page whose only controls are radio/checkbox questions and a Continue is a @@ -633,6 +648,77 @@ public async Task RevealFormAsync() return; } + // The posting, while it is still on screen: Apply replaces it. + await KeepPostingTextAsync(); + // Apply may lead to another posting page with its own Apply: an aggregator (7seventy) + // opens the employer's Ashby posting in a new tab, and that page's "Apply for this Job" is + // the way to the form. Nobody pressed it, and the run said "no form appeared" (#426). + // One more hop, only onto a page Apply actually led to — never the same page twice. + string applyHost = ""; + for (var hop = 0; ; hop++) + { + var outcome = await PressApplyAsync(apply, canHop: hop == 0); + if (outcome.Done) return; + if (outcome.ApplyHost.Length > 0 && applyHost.Length == 0) applyHost = outcome.ApplyHost; + if (hop == 0 && outcome.Moved && await FindApplyAsync() is { } again) + { + apply = again; + continue; + } + break; + } + var landedAt = Uri.TryCreate(Page.Url, UriKind.Absolute, out var at) ? at.Host : ""; + RevealNote = NoFormNote(Refused, landedAt, applyHost); + if (AccountOnlyAts(landedAt) is not null) NeedsAccountAt = landedAt; + else if (Refused.Count > 0 && AccountOnlyAts(Refused[0]) is not null) NeedsAccountAt = Refused[0]; + else if (AccountOnlyAts(applyHost) is not null) NeedsAccountAt = applyHost; + } + + /// + /// The visible way into the application, or null. By accessible name first, then by what the + /// control visibly says. Allstate labels its link aria-label="Lead .Net Software Engineer Apply + /// Now open in new window": the name opens with the job title, so nothing "began with apply" + /// and the run reported "no Apply button or link on the page" with "Apply now" in plain view + /// (#280). And the first one that can be SEEN, not the first in the document — a collapsed + /// panel's own "Apply" must not stand in for the posting's. The top document first, then the + /// frames it embeds: Voleon's careers page embeds its Ashby posting in an iframe, and the + /// posting's "Apply for this Job" is in there, out of reach of a search of the page (#426). + /// + private async Task FindApplyAsync() + { + foreach (var frame in new[] { Page.MainFrame }.Concat(Page.Frames.Where(f => f != Page.MainFrame))) + { + foreach (var matches in new[] + { + frame.GetByRole(AriaRole.Button, new() { NameRegex = ApplyTrigger() }).Filter(new() { HasNotTextRegex = LaterWords() }), + frame.GetByRole(AriaRole.Link, new() { NameRegex = ApplyTrigger() }).Filter(new() { HasNotTextRegex = LaterWords() }), + frame.Locator("a[href], button, [role=button], input[type=submit], input[type=button]") + .Filter(new() { HasTextRegex = ApplyTrigger() }).Filter(new() { HasNotTextRegex = LaterWords() }), + }) + { + try + { + var count = Math.Min(await matches.CountAsync(), 8); + for (var i = 0; i < count; i++) + if (await matches.Nth(i).IsVisibleAsync()) return matches.Nth(i); + } + catch (PlaywrightException) { /* next */ } + } + } + return null; + } + + /// What one press of Apply came to: Done when the reveal is settled (a form, + /// a sign-in, a wall, or a click that failed — says which); + /// otherwise nothing appeared, and Moved says whether Apply led to another page or tab. + private readonly record struct ApplyPress(bool Done, bool Moved, string ApplyHost); + + /// An address without its #fragment: "moved" means another tab or another address, + /// not a fragment an overlay or an in-page anchor set on the same page (#426). + private static string Unfragmented(string url) => url.Split('#')[0]; + + private async Task PressApplyAsync(ILocator apply, bool canHop) + { // Where Apply points, read before the click: Allstate's is a target="_blank" anchor to // Workday, and when the tab it opens never reaches this context the run had nothing to // go on and said "no form appeared" about a page it had never left (#280). The href @@ -647,8 +733,10 @@ public async Task RevealFormAsync() } catch (PlaywrightException) { /* a button, not a link */ } - // The posting, while it is still on screen: Apply replaces it. - await KeepPostingTextAsync(); + var urlBefore = Page.Url; + // The tabs already open are not what this Apply opened (the posting Apply led away from, + // on a second hop). + var known = _context.Pages.ToHashSet(); var popup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); void OnPage(object? _, IPage p) => popup.TrySetResult(p); _context.Page += OnPage; @@ -670,13 +758,13 @@ public async Task RevealFormAsync() if (attempt == 0 && await DismissConsentAsync(Page)) continue; _context.Page -= OnPage; RevealNote = "the Apply button did not respond within 5 s"; - return; + return new(true, false, applyHost); } catch (PlaywrightException ex) { _context.Page -= OnPage; RevealNote = "clicking Apply failed: " + ex.Message.Split('\n')[0]; - return; + return new(true, false, applyHost); } } // Gainwell's "Apply Now" only opens a menu — "Apply Now" / "Start applying with LinkedIn" — @@ -696,6 +784,7 @@ public async Task RevealFormAsync() // Apply opens its sign-in in a new tab only after a round trip, later than the moment // after the click, and a run that kept watching the posting saw nothing (#235). var deadline = DateTime.UtcNow.AddSeconds(10); + var settled = DateTime.UtcNow.AddSeconds(5); var adopted = false; try { @@ -705,7 +794,7 @@ public async Task RevealFormAsync() // handler was attached — or one the event never fired for — is still in the // context, so the context's own list is asked too. if (!adopted && !popup.Task.IsCompletedSuccessfully - && _context.Pages.FirstOrDefault(p => p != Page && !p.IsClosed) is { } stray) + && _context.Pages.FirstOrDefault(p => !known.Contains(p) && !p.IsClosed) is { } stray) popup.TrySetResult(stray); if (!adopted && popup.Task.IsCompletedSuccessfully) { @@ -718,13 +807,14 @@ public async Task RevealFormAsync() // A new tab is a whole app booting through the proxy: Alignerr's goes /signup → // /signin behind a spinner and showed its wall well after ten seconds on pluto. deadline = DateTime.UtcNow.AddSeconds(30); + settled = DateTime.UtcNow.AddSeconds(5); } // A field on screen settles it — and only then is the sign-in question asked, so a // page still mid-navigation cannot answer "no sign-in" a moment before it renders one. if (await AnyFieldVisibleAsync(Page)) { if (await SignInFormVisibleAsync(Page)) await TrySignInAsync(); - return; + return new(true, true, applyHost); } // Alignerr and its kind: Apply opens an account sign-up that only offers "Continue // with Google / LinkedIn" — no username box, no form, nothing the browser can fill or @@ -734,18 +824,20 @@ public async Task RevealFormAsync() if (providers.Count > 0) { RevealNote = SocialWallNote(Page.Url, providers); - return; + return new(true, true, applyHost); } if (DateTime.UtcNow >= deadline) break; + // Landed on another posting page that has its own Apply and, after a few seconds to + // render, still no form: that Apply is the way on, and waiting out the thirty + // seconds a new tab is given only delays pressing it (#426). + if (canHop && DateTime.UtcNow >= settled && (adopted || Unfragmented(Page.Url) != Unfragmented(urlBefore)) + && await FindApplyAsync() is not null) + break; await Page.WaitForTimeoutAsync(500); } } finally { _context.Page -= OnPage; } - var landedAt = Uri.TryCreate(Page.Url, UriKind.Absolute, out var at) ? at.Host : ""; - RevealNote = NoFormNote(Refused, landedAt, applyHost); - if (AccountOnlyAts(landedAt) is not null) NeedsAccountAt = landedAt; - else if (Refused.Count > 0 && AccountOnlyAts(Refused[0]) is not null) NeedsAccountAt = Refused[0]; - else if (AccountOnlyAts(applyHost) is not null) NeedsAccountAt = applyHost; + return new(false, adopted || Unfragmented(Page.Url) != Unfragmented(urlBefore), applyHost); } /// diff --git a/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs b/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs index c2a5f0b..cb2ed71 100644 --- a/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs +++ b/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs @@ -79,9 +79,16 @@ public sealed partial class BrowserSubmitter : IBrowserSubmitter // prose ("no longer supported", etc.) never trips it. // "Page not found" is what Greenhouse serves for a posting that has been taken down // outright (Cresteo and Varicent on 2026-09-13): no gone-notice, no form, a 404 page. - [GeneratedRegex(@"job you are looking for is no longer open|no longer (?:open|accepting applications)|(?:position|posting|job|role|opening) (?:has been|was|is now) (?:filled|closed)|this (?:position|posting|job|role|opening|opportunity) is (?:no longer available|closed|not available anymore|currently not available)|\bpage not found\b|\bjob (?:posting )?not found\b|this job (?:posting )?(?:is )?no longer exists", RegexOptions.IgnoreCase)] + // And three that read as "no Apply button" until they were taught (#427): SmartRecruiters' + // "Sorry, this job has expired", Ceipal's "We are no longer accepting new profiles for this + // position", MeeBoss's "The employer has taken the job offline". + [GeneratedRegex(@"job you are looking for is no longer open|no longer (?:open|accepting (?:new )?(?:applications|applicants|candidates|profiles|resumes))|this (?:job|posting|position|role) has expired|(?:taken|took) (?:the|this) (?:job|posting|position|role) offline|(?:position|posting|job|role|opening) (?:has been|was|is now) (?:filled|closed)|this (?:position|posting|job|role|opening|opportunity) is (?:no longer available|closed|not available anymore|currently not available)|\bpage not found\b|\bjob (?:posting )?not found\b|this job (?:posting )?(?:is )?no longer exists", RegexOptions.IgnoreCase)] private static partial Regex ClosedPosting(); + /// A bot-protection service's refusal page: Cloudflare, Akamai, Imperva, DataDome (#428). + [GeneratedRegex(@"you have been blocked|attention required!? \| cloudflare|access denied|request (?:was |has been )?blocked|pardon our interruption|verify you are (?:a )?human", RegexOptions.IgnoreCase)] + private static partial Regex BotBlock(); + [GeneratedRegex(@"/(?:home|careers?|jobs?|openings|positions|opportunities|search|job-?board)/?$", RegexOptions.IgnoreCase)] private static partial Regex JobListPath(); @@ -205,7 +212,10 @@ private async Task RunOnPageAsync( // The site will not talk to this server at all — McGraw Hill's careers site answers // pluto with a bare "403 Forbidden". That is not a page with no Apply button on it, // which is what the run used to report, and no retry will change it (#280). - if (session.Status is 401 or 403 or 429 or 451 && body.Length < 600) + // Cloudflare's own block page is longer than that, and remotehunter.com's read as "no + // Apply button or link on the page" (#428): a bot-protection block is the same verdict + // at any length. + if (session.Status is 401 or 403 or 429 or 451 && (body.Length < 600 || BotBlock().IsMatch(body))) { screenshot = await session.ScreenshotAsync(); return new SubmitOutcome(false, false, page.Url, "", screenshot, unmapped, mapped, @@ -1115,7 +1125,14 @@ private static async Task CommittedAsync(ILocator control, string chosen) if (widget(el)) continue; // a search or job-alert box is never a required field (#214) const type = (el.getAttribute('type') || (el.tagName === 'SELECT' ? 'select' : 'text')).toLowerCase(); if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type) || el.disabled) continue; - if (!(el.required || el.getAttribute('aria-required') === 'true')) continue; + // Required by the star in its label alone, the way discovery reads it: HubSpot's own + // rendering of its Greenhouse form marks a dozen selects "… *" and nothing else, the + // sweep let them all through blank, and Submit stayed disabled — "no Submit button + // found" (#425). Boxes, selects and textareas only, on screen: a choice group's star + // is on its title, which discovery and the radio rule below already read. + const starred = !['radio', 'checkbox', 'file'].includes(type) && visible(el) && !el.readOnly + && /^\s*\*|\*\s*$/.test((labelFor(el) || '').trim()); + if (!(el.required || el.getAttribute('aria-required') === 'true' || starred)) continue; if (sideForm(el)) continue; if (type === 'file') { // Not gated on the input being visible: uploaders keep theirs off screen behind @@ -1162,6 +1179,11 @@ private static async Task CommittedAsync(ILocator control, string chosen) continue; } } + // A select still on its placeholder is empty even when the placeholder carries a value: + // HubSpot's are (#425). + else if (el.tagName === 'SELECT') + empty = (el.value || '').trim().length === 0 + || (el.selectedIndex === 0 && /^\s*-*\s*(?:select|choose|please (?:select|choose)|pick)\b/i.test(el.options[0]?.text || '')); else empty = (el.value || '').trim().length === 0; if (empty) add(el.id || el.getAttribute('name'), labelFor(el)); } @@ -1308,7 +1330,7 @@ public static string CandidateEmail(AgentPacket packet) if (email.Length == 0) return Stop(SignInError + ", and the packet has no email address to sign in with"); - var box = form.Locator("input[type=email]:visible").First; + var box = form.Locator(EmailBox).First; var textBefore = await BodyTextAsync(form); var urlBefore = page.Url; try @@ -1394,10 +1416,15 @@ private static async Task AfterContinueAsync(IPage page, string text } } + /// A sign-in's email box: type=email, or a text box that names itself one (#429). + private const string EmailBox = + "input[type=email]:visible, input[type=text][autocomplete=email]:visible, input[type=text][name*=email i]:visible, " + + "input[type=text][id*=email i]:visible, input[type=text][placeholder*=email i]:visible, input:not([type])[placeholder*=email i]:visible"; + /// Is the sign-in's own email box still on screen? False mid-navigation too. private static async Task EmailBoxShownAsync(IPage page) { - try { return await page.MainFrame.Locator("input[type=email]:visible").First.IsVisibleAsync(); } + try { return await page.MainFrame.Locator(EmailBox).First.IsVisibleAsync(); } catch (PlaywrightException) { return false; } } @@ -1508,6 +1535,8 @@ private static async Task SubmitOnPageAsync(IFrame page) page.Locator("#submit_app, [id$='_submitBtn']").Filter(notLater).First, page.GetByRole(AriaRole.Button, new() { NameRegex = new Regex(@"^submit\b|submit (?:my |your |the )?application", RegexOptions.IgnoreCase) }).Filter(notLater).First, page.Locator("button[type=submit], input[type=submit]").Filter(new() { HasTextRegex = new Regex(@"^\s*(?:submit|apply)\b", RegexOptions.IgnoreCase) }).Filter(notLater).First, + // A script's anchor that submits the form (#424). + page.Locator("form").Locator(FormAnchorSubmit).Filter(new() { HasTextRegex = AnchorSubmitWords() }).Filter(notLater).First, }) { try { if (await c.CountAsync() > 0 && await c.IsVisibleAsync()) return true; } @@ -1541,6 +1570,16 @@ private async Task