diff --git a/.env.production.example b/.env.production.example
index f807c91..cee47da 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -1,7 +1,7 @@
# Production deployments intentionally have no working secret defaults.
# Copy this file to .env.production, replace every CHANGE-ME value, keep the
# result out of version control, and pin a released OSApplyTrack version.
-OSAPPLYTRACK_VERSION=1.66.6
+OSAPPLYTRACK_VERSION=1.67.0
POSTGRES_USER=applytrack
POSTGRES_PASSWORD=CHANGE-ME-use-a-long-random-password
POSTGRES_DB=applytrack
diff --git a/BACKLOG.md b/BACKLOG.md
index a099068..ff42328 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -162,9 +162,33 @@ review items and unsupported forms still need human attention.
- [x] [#394 — Due view: follow up agentically — the agent drafts (and optionally sends) the follow-up](https://github.com/CryptoJones/OSApplyTrack/issues/394) (fixed in 1.66.0: **Follow up** on each Due row and **Follow up on all** walk the follow-ups in one dialog — the tenant's model (any OpenAI-compatible endpoint, metered like any draft, no call at all with drafting off) writes a short email from the application and its dates, the contact, the cover letter, the notes log and the last messages the mailbox has exchanged with that contact; the person edits it, then **Send** mails it from their **own** account (new SMTP host/port on the mailbox settings, same username and sealed app password, host pinned to a public address like the IMAP side) — never the instance's sender — or **Mark followed up** logs one made elsewhere; either way it lands in the notes log and the date moves or clears. No contact on file: the row says so and the draft is still written, to copy. Unreviewed sending split out as #398)
- [x] [#401 — Mobile: Errors and Due tables overflow or crush on a phone — Retry cut off](https://github.com/CryptoJones/OSApplyTrack/issues/401) (fixed in 1.66.1)
- [ ] [#398 — Follow-ups: opt-in to let the agent send a drafted follow-up without review](https://github.com/CryptoJones/OSApplyTrack/issues/398)
-- [ ] [#408 — Agent: LinkedIn SDUI Easy Apply — the contact step's City typeahead is still left unmapped (SMX)](https://github.com/CryptoJones/OSApplyTrack/issues/408) (found re-running the Ready lane after #406: `CityLabel()` matches and the résumé location is typed, but the typeahead is never committed on the SDUI dialog; the run stops at the contact step with `City` the only unmapped field)
+- [x] [#408 — Agent: LinkedIn SDUI Easy Apply — the contact step's City typeahead is still left unmapped (SMX)](https://github.com/CryptoJones/OSApplyTrack/issues/408) (fixed in 1.67.0: the "City" was a Work-experience role editor LinkedIn opens on Next, not the contact City; the driver now answers what Next brings up, names each role's fields, and fills Description from the résumé. Found re-running the Ready lane after #406: `CityLabel()` matches and the résumé location is typed, but the typeahead is never committed on the SDUI dialog; the run stops at the contact step with `City` the only unmapped field)
- [ ] [#410 — Agent: a packet's whole answer draft rides one LLM completion under the API's 120 s timeout — Orbis's 29-question form timed out twice and every answer was voided](https://github.com/CryptoJones/OSApplyTrack/issues/410) (not the network or the endpoint — a 36 KB request from the agent's own netns answers in 1.5 s; the real 22-question prompt just takes poolside longer than two minutes. Mitigated on pluto with `Llm__TimeoutSeconds=600` on the agent; the fix is a worker-side ceiling, chunked drafting, or both)
-- [ ] [#409 — Agent: LinkedIn SDUI Easy Apply — Trility's dialog never reaches Submit within 12 steps](https://github.com/CryptoJones/OSApplyTrack/issues/409) (`mapped: 0, unmapped: []` — the driver presses Next twelve times on a step it cannot leave and reports nothing about it; needs the screenshot and the live dialog)
+- [x] [#409 — Agent: LinkedIn SDUI Easy Apply — Trility's dialog never reaches Submit within 12 steps](https://github.com/CryptoJones/OSApplyTrack/issues/409) (fixed in 1.67.0: the dialog really is 14 pages; the limit is 40 and a Next that changes nothing twice fails naming the page. Was: `mapped: 0, unmapped: []` — the driver presses Next twelve times on a step it cannot leave and reports nothing about it; needs the screenshot and the live dialog)
+- [x] [#431 — Agent: a checkbox group is discovered as one Yes/No question per option (Fieldwire, Stripe) — fixed in 1.67.0: one multiselect titled by its legend; old split packets still fill](https://github.com/CryptoJones/OSApplyTrack/issues/431)
+- [x] [#432 — Agent: a react-select question is discovered with no options, so drafted prose matches no choice — fixed in 1.67.0: menus are opened and read; prose is fitted to the option it means](https://github.com/CryptoJones/OSApplyTrack/issues/432)
+- [x] [#433 — Agent: Greenhouse education picked from the résumé stays listed unmapped (Stripe) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/433)
+- [x] [#434 — Agent: an employer page with ?ashby_jid= is not followed to Ashby's form (Hercules) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/434)
+- [x] [#435 — Agent: a posting that renders its gone-notice late is reported as an unfillable form (Torentify, Fullstack) — fixed in 1.67.0: retired as closed](https://github.com/CryptoJones/OSApplyTrack/issues/435)
+- [x] [#436 — Agent: page scripts cannot see a form drawn inside a shadow root (Manatal careers-page.com) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/436)
+- [x] [#437 — Agent: Flexhire's notice blocks the résumé button and its Filestack picker is never finished — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/437)
+- [x] [#438 — Agent: phone numbers rejected — an international widget's dial code, and a résumé parse overwriting the box (Motion Recruitment, Robert Half) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/438)
+- [x] [#439 — Agent: a generic placeholder ("Enter") is taken for a field's label — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/439)
+- [ ] [#440 — Agent: Manatal (careers-page.com) Radix selects and checkbox are not driven](https://github.com/CryptoJones/OSApplyTrack/issues/440) (Yo AI Labs: after #436 the résumé attaches and the hidden questions are found, but the Nationalities / years / salary Radix selects and the terms checkbox are custom widgets neither discovered nor driven)
+- [x] [#417 — Agent: a résumé dropzone whose wrapping label is only "Click or drag & drop PDF" is never recognised as the résumé (Ethos) — fixed in 1.67.0: dropzone text never names a file field; its row's title does](https://github.com/CryptoJones/OSApplyTrack/issues/417)
+- [x] [#418 — Agent: an application form folded behind "Easy Apply" is never revealed (N-iX) — fixed in 1.67.0: "Easy/Quick/One-click Apply" open the form (N-iX itself then ends at a reCAPTCHA v2 handoff)](https://github.com/CryptoJones/OSApplyTrack/issues/418)
+- [x] [#419 — Agent: a footer newsletter signup (mc4wp) is discovered as an application question (Tecbrains) — fixed in 1.67.0: newsletter forms are page widgets](https://github.com/CryptoJones/OSApplyTrack/issues/419)
+- [x] [#420 — Agent: Lever's invisible hCaptcha holds Submit and nothing is posted — fixed in 1.67.0: a Submit that sends nothing on a form with a captcha widget is a captcha handoff; parseResume is uploader chatter](https://github.com/CryptoJones/OSApplyTrack/issues/420)
+- [x] [#421 — Agent: Ashby's GraphQL submit answer is never read — fixed in 1.67.0: FormSubmitSuccess confirms, errors[].message is reported](https://github.com/CryptoJones/OSApplyTrack/issues/421)
+- [x] [#422 — Agent: drafter: "hourly" matched the website rule's "url" — fixed in 1.67.0: "url" as a whole word; hourly rate is a salary question](https://github.com/CryptoJones/OSApplyTrack/issues/422)
+- [x] [#423 — Agent: the Apply finder took English "Candidate …" for Apply (Ford) and missed Spanish "Postular" — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/423)
+- [x] [#424 — Agent: a form whose Submit is a JavaScript anchor ends "no Submit button found" (Tential, JazzHR) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/424)
+- [x] [#425 — Agent: required-empty sweep ignored a starred label and a valued placeholder option (HubSpot) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/425)
+- [x] [#426 — Agent: Apply inside an embedded ATS iframe, or a second Apply on the page Apply led to, was never pressed (Voleon, 7seventy) — fixed in 1.67.0, plus noopener tabs no longer hang and ad vignettes are blocked](https://github.com/CryptoJones/OSApplyTrack/issues/426)
+- [x] [#427 — Agent: expired / "no longer accepting profiles" / "taken offline" postings read as "no Apply button" — fixed in 1.67.0: reported as closed](https://github.com/CryptoJones/OSApplyTrack/issues/427)
+- [x] [#428 — Agent: Cloudflare's block page read as "no Apply button" — fixed in 1.67.0: "the site refused this server"](https://github.com/CryptoJones/OSApplyTrack/issues/428)
+- [x] [#429 — Agent: ClearCompany's returning-applicant email sign-in mid-wizard — fixed in 1.67.0: an email-only page is a sign-in, its Next is never pressed](https://github.com/CryptoJones/OSApplyTrack/issues/429)
+- [x] [#430 — Agent: LinkedIn Easy Apply timed out behind the "Job search safety reminder" (KRON) — fixed in 1.67.0: "Continue applying" is pressed](https://github.com/CryptoJones/OSApplyTrack/issues/430)
- [x] [#416 — Agent: two controls under one label — OneStream's address Country lost to the phone's country picker](https://github.com/CryptoJones/OSApplyTrack/issues/416) (fixed in 1.66.6: when a locator matches several controls, the one whose id or name is the question's own id wins; a react-select reads empty after a choice, so "prefer the empty one" took the phone's picker every time.)
- [x] [#414 — Agent: a consent question that mentions the phone number gets answered with the phone number (Prenuvo SMS opt-in)](https://github.com/CryptoJones/OSApplyTrack/issues/414) (fixed in 1.66.6: the drafter's phone and email rules skip a label that asks for consent, opt-in or agreement, or names SMS, text messages, WhatsApp or subscribing; plain "Phone" / "Mobile phone number" still take the number.)
- [x] [#412 — Agent: LinkedIn SDUI years-of-experience text boxes refuse prose answers ("5+ years")](https://github.com/CryptoJones/OSApplyTrack/issues/412) (fixed in 1.66.5: the SDUI dialog draws "How many years …?" as a plain 20-character text box, so the #332 whole-number coercion keyed on `type=number` never fired and the drafter's "10+ years — …" was refused after Next. A label that asks "How many years" now gets the whole number its answer states, and "Less than 1 year" reads 0, not 1. Found on the 2026-10-05 Ready sweep: seven LinkedIn rows rebuilt on Claude answered every question and still dry-ran 0 mapped.)
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.FormShapes.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.FormShapes.cs
new file mode 100644
index 0000000..35b55d2
--- /dev/null
+++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.FormShapes.cs
@@ -0,0 +1,461 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Aaron K. Clark
+
+using ApplyTrack.Api.Agent;
+using ApplyTrack.Api.Agent.Browser;
+using ApplyTrack.Api.Data;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ApplyTrack.Api.Tests;
+
+///
+/// Form shapes met on the 2026-10-06 Ready rows (#431–#439): Greenhouse's checkbox groups and
+/// react-select menus (Fieldwire, Stripe), its Education pickers, a gone-notice drawn late
+/// (Torentify), a form inside a shadow root (Manatal), a notice over the form and a Filestack
+/// picker (Flexhire), an international phone box and a résumé parse that rewrites fields
+/// (Motion Recruitment, Robert Half), and boxes whose placeholder is only "Enter" (Fullstack).
+///
+public sealed partial class BrowserSubmitterTests
+{
+ private static void MapFormShapeFixtures(WebApplication app)
+ {
+ app.MapGet("/jobs/gh-shapes", () => Results.Content(GreenhouseShapesHtml, "text/html"));
+ app.MapGet("/jobs/gh-education", () => Results.Content(GreenhouseEducationHtml, "text/html"));
+ app.MapGet("/jobs/late-gone", () => Results.Content(LateGoneHtml, "text/html"));
+ app.MapGet("/jobs/shadow-form", () => Results.Content(ShadowFormHtml, "text/html"));
+ app.MapGet("/jobs/notice-picker", () => Results.Content(NoticePickerHtml, "text/html"));
+ app.MapGet("/jobs/phone-widget", () => Results.Content(PhoneWidgetHtml, "text/html"));
+ app.MapGet("/jobs/enter-placeholders", () => Results.Content(EnterPlaceholdersHtml, "text/html"));
+ }
+
+ // A react-select stand-in: an whose menu exists only while open and
+ // filters on what is typed, a hidden required input holding the committed value, and the
+ // value posted under the question's name. "multi" keeps several (value container --is-multi).
+ private const string ComboScript = """
+
+ """;
+
+ private static string Combo(string id, string label, bool multi = false) => $"""
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private static readonly string GreenhouseShapesHtml = $$"""
+
+ {{ComboScript}}
+
+
+
+ """;
+
+ private static readonly string GreenhouseEducationHtml = $$"""
+
+ {{ComboScript}}
+
+
+
+ """;
+
+ // Torentify: the posting page renders, then its script asks for the job and draws the
+ // gone-notice — after the run's first look. Only the site's own search box is on it.
+ private const string LateGoneHtml = """
+
+
+
Loading…
+
+
+ """;
+
+ // Manatal: the whole application lives in a web component's open shadow root; labels carry
+ // the only required mark, a star.
+ private const string ShadowFormHtml = """
+
+
Software Developer
+
+
+
+ """;
+
+ // Flexhire: Apply opens the form under a notice ("we couldn't detect your location") whose
+ // only button is Ok and whose one link is set in its sentence; "Upload Resume/CV" opens a
+ // picker that makes its own file input and sends nothing until its Upload is pressed.
+ private const string NoticePickerHtml = """
+
+
+
+
+
Apply to Job
+
This job accepts applicants located in Argentina, Bolivia, Brazil . We couldn't detect your location, set it below to see if you are eligible to apply.
+
+
+
+
+
+ """;
+
+ // Motion Recruitment's react-international-phone: the box opens on "+1 " and resets itself
+ // when its value loses the dial code — a fill() wipes it. Robert Half's résumé parse: the
+ // upload rewrites the name and phone the run had typed.
+ private const string PhoneWidgetHtml = """
+
+
+
+
+ """;
+
+ // Fullstack's talent portal: every box's placeholder is "Enter"; the field's title is the
+ // text above it, with its star.
+ private const string EnterPlaceholdersHtml = """
+
+
+
+ """;
+
+ [SkippableFact]
+ public async Task A_checkbox_group_and_react_select_menus_are_discovered_whole()
+ {
+ // Fieldwire / Stripe: each box of a "select all that apply" was its own Yes/No question,
+ // the group's id labelled by its first option (#431); react-select questions came back as
+ // free text with no options, and the drafter wrote prose to them (#432).
+ Skip.IfNot(Available, "Node Playwright is not installed (npm ci)");
+ var questions = await Discoverer().DiscoverAsync($"{_fixtureUrl}/jobs/gh-shapes");
+ Assert.NotNull(questions);
+ var cloud = Assert.Single(questions!, q => q.Id == "question_10[]");
+ Assert.Equal(PacketQuestion.MultiSelect, cloud.Type);
+ Assert.Equal(["AWS", "GCP", "Azure"], cloud.Options);
+ Assert.Equal("Do you have professional working experience with cloud infrastructure tools?", cloud.Label);
+ Assert.True(cloud.Required);
+ Assert.DoesNotContain(questions!, q => q.Id is "GCP" or "Azure");
+ var auth = Assert.Single(questions!, q => q.Id == "question_7");
+ Assert.Equal(PacketQuestion.Select, auth.Type);
+ Assert.Equal(["Yes", "No"], auth.Options);
+ var langs = Assert.Single(questions!, q => q.Id == "question_8[]");
+ Assert.Equal(PacketQuestion.MultiSelect, langs.Type);
+ Assert.Equal(["Ruby on Rails", "Rust", "Both", "Neither"], langs.Options);
+ }
+
+ [SkippableFact]
+ public async Task A_packet_that_split_a_checkbox_group_and_wrote_prose_to_menus_still_fills_the_form()
+ {
+ // The packets already in Ready: per-box Yes/No questions, and drafted prose for menus
+ // the drafter never saw. "No" boxes must stay unticked; the prose picks what it means.
+ Skip.IfNot(Available, "Node Playwright is not installed (npm ci)");
+ var packet = new AgentPacket
+ {
+ ApplicationName = "fieldwire-backend.md", Provider = "greenhouse",
+ Questions =
+ [
+ new("first_name", "First Name", true, PacketQuestion.Text, [], PacketQuestion.Standard),
+ new("question_7", "Are you legally authorized to work in the U.S. without any restrictions?", true, PacketQuestion.Text, [], PacketQuestion.Custom),
+ new("question_8[]", "Do you have experience coding with any of the languages listed below?", true, PacketQuestion.Text, [], PacketQuestion.Custom),
+ new("question_9", "Please select the country where you currently reside.", true, PacketQuestion.Text, [], PacketQuestion.Custom),
+ new("question_10[]", "AWS", true, PacketQuestion.Select, ["Yes", "No"], PacketQuestion.Custom),
+ new("GCP", "GCP", true, PacketQuestion.Select, ["Yes", "No"], PacketQuestion.Custom),
+ new("Azure", "Azure", true, PacketQuestion.Select, ["Yes", "No"], PacketQuestion.Custom),
+ ],
+ Answers = new()
+ {
+ ["first_name"] = "Ada",
+ ["question_7"] = "US Citizen",
+ ["question_8[]"] = "Yes — C# and Python are my deepest languages, plus Rust (a hexagonal core). No Ruby on Rails experience.",
+ ["question_9"] = "United States of America",
+ ["question_10[]"] = "Yes", ["GCP"] = "No", ["Azure"] = "Yes",
+ },
+ };
+ var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/gh-shapes", packet, null, dryRun: false);
+ Assert.True(outcome.Submitted, outcome.Error + " unmapped: " + string.Join(", ", outcome.Unmapped));
+ var post = Assert.Single(_posts);
+ Assert.Equal("Yes", post["question_7"]);
+ Assert.Equal("Rust", post["question_8[]"]);
+ Assert.Equal("US", post["question_9"]);
+ Assert.Equal("1,3", post["question_10[]"]);
+ }
+
+ [SkippableFact]
+ public async Task Education_the_resume_filled_is_not_left_listed_unmapped()
+ {
+ // Stripe: the packet's "University of Maryland University College" is no option, the
+ // résumé's school then filled the picker — and the run still listed it unmapped (#433).
+ Skip.IfNot(Available, "Node Playwright is not installed (npm ci)");
+ var packet = new AgentPacket
+ {
+ ApplicationName = "stripe-backend.md", Provider = "greenhouse",
+ Questions =
+ [
+ new("first_name", "First Name", true, PacketQuestion.Text, [], PacketQuestion.Standard),
+ new("school--0", "School", true, PacketQuestion.Text, [], PacketQuestion.Custom),
+ new("degree--0", "Degree", true, PacketQuestion.Text, [], PacketQuestion.Custom),
+ ],
+ Answers = new()
+ {
+ ["first_name"] = "Ada",
+ ["school--0"] = "University of Maryland University College",
+ ["degree--0"] = "Bachelor of Science, Computer and Information Science",
+ },
+ Resume = new Resume { Education = [new("University of Maryland University College", "Bachelor of Science", "Computer and Information Science", "2010 - 2014")] },
+ };
+ var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/gh-education", packet, null, dryRun: true);
+ Assert.True(outcome.Filled, outcome.Error);
+ Assert.Empty(outcome.Unmapped);
+ Assert.Contains("school--0", outcome.Mapped);
+ Assert.Contains("degree--0", outcome.Mapped);
+ }
+
+ [SkippableFact]
+ public async Task A_gone_notice_drawn_after_the_first_look_retires_the_posting()
+ {
+ // Torentify: "Job Not Found" arrived after the closed-posting check, and the run said
+ // "nothing on this form could be filled" about the site's search box (#435).
+ Skip.IfNot(Available, "Node Playwright is not installed (npm ci)");
+ var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/late-gone", StandardPacket(), (Pdf, "resume.pdf"), dryRun: true);
+ Assert.True(outcome.Closed, outcome.Error);
+ Assert.Empty(_posts);
+ }
+
+ [Fact]
+ public void Gone_notices_and_ashby_embeds_are_read_from_their_words()
+ {
+ Assert.True(BrowserSubmitter.IsClosedPosting("Oops! We couldn’t find that page. The page might have been relocated.")); // Fullstack (#435)
+ Assert.True(BrowserSubmitter.IsClosedPosting("Job Not Found The job you are looking for could not be found."));
+ Assert.False(BrowserSubmitter.IsClosedPosting("We couldn't find a better team to join."));
+ // Hercules: ?ashby_jid= on the employer's own careers page (#434).
+ const string link = "https://hercules.app/careers?ashby_jid=9ee9a087-9c1b-40e3-ab15-f28d42802314&utm_source=x";
+ Assert.True(AtsProvider.HasAshbyJobId(link));
+ Assert.Equal("https://jobs.ashbyhq.com/hercules/9ee9a087-9c1b-40e3-ab15-f28d42802314/application", AtsProvider.AshbyHostedForm(link));
+ Assert.Equal("https://jobs.ashbyhq.com/acme-labs/9ee9a087-9c1b-40e3-ab15-f28d42802314/application",
+ AtsProvider.AshbyHostedForm("https://careers.acme.io/?ashby_jid=9ee9a087-9c1b-40e3-ab15-f28d42802314",
+ ""));
+ Assert.Null(AtsProvider.AshbyHostedForm("https://jobs.ashbyhq.com/hercules/9ee9a087-9c1b-40e3-ab15-f28d42802314"));
+ Assert.Null(AtsProvider.AshbyHostedForm("https://hercules.app/careers"));
+ }
+
+ [Fact]
+ public void A_menu_choice_is_what_the_answer_means()
+ {
+ // #432: prose drafted to a menu the drafter never saw.
+ Assert.Equal(["Yes"], BrowserSubmitter.MenuChoices(["Yes", "No"], "Yes. At Ronin 48 I build AI features.", false));
+ Assert.Equal(["No"], BrowserSubmitter.MenuChoices(["Yes", "No"], "No, I do not.", false));
+ Assert.Equal(["Yes"], BrowserSubmitter.MenuChoices(["Yes", "No"], "US Citizen", false, "Are you legally authorized to work in the U.S.?"));
+ Assert.Empty(BrowserSubmitter.MenuChoices(["Yes", "No"], "US Citizen", false, "Will you require sponsorship?"));
+ Assert.Empty(BrowserSubmitter.MenuChoices(["Yes", "No"], "Not without sponsorship", false));
+ Assert.Equal(["US"], BrowserSubmitter.MenuChoices(["Australia", "UK", "US", "Other"], "United States of America", false));
+ Assert.Equal(["Rust"], BrowserSubmitter.MenuChoices(["Ruby on Rails", "Rust", "Both", "Neither"],
+ "C# and Python, plus Rust. No Ruby on Rails experience.", true));
+ Assert.Equal(["AWS", "Azure"], BrowserSubmitter.MenuChoices(["AWS", "GCP", "Azure"], "AWS, Azure", true));
+ Assert.Empty(BrowserSubmitter.MenuChoices(["Seattle", "New York"], "I live in Nebraska.", false));
+ }
+
+ [SkippableFact]
+ public async Task A_form_inside_a_shadow_root_is_discovered_and_its_resume_seen_to_take()
+ {
+ // Manatal (yo-ai-labs): discovery found nothing, and the attached résumé was never seen,
+ // "required fields could not be mapped (std:resume)" (#436).
+ Skip.IfNot(Available, "Node Playwright is not installed (npm ci)");
+ var questions = await Discoverer().DiscoverAsync($"{_fixtureUrl}/jobs/shadow-form");
+ Assert.NotNull(questions);
+ Assert.Contains(questions!, q => q.Id == "full_name" && q.Label == "What is your full name?" && q.Required);
+ Assert.Contains(questions!, q => q.Id == "position" && q.Required);
+
+ var packet = StandardPacket();
+ var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/shadow-form", packet, (Pdf, "resume.pdf"), dryRun: true);
+ Assert.Contains("std:resume", outcome.Mapped);
+ Assert.DoesNotContain("std:resume", outcome.Unmapped);
+ // The starred position box the packet never knew is named, and handed to the drafter.
+ Assert.Contains("position", outcome.Unmapped);
+ Assert.Contains(outcome.Discovered ?? [], q => q.Id == "position");
+ }
+
+ [SkippableFact]
+ public async Task A_notice_over_the_form_is_acknowledged_and_a_picker_upload_is_finished()
+ {
+ // Flexhire: the Ok-only notice took the résumé button's click, and the Filestack picker
+ // made a file input and waited for its own Upload — "Resume is required" (#437).
+ Skip.IfNot(Available, "Node Playwright is not installed (npm ci)");
+ var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/notice-picker", StandardPacket(), (Pdf, "resume.pdf"), dryRun: false);
+ Assert.True(outcome.Submitted, outcome.Error + " unmapped: " + string.Join(", ", outcome.Unmapped));
+ var post = Assert.Single(_posts);
+ Assert.Equal("resume.pdf", post["resume_name"]);
+ Assert.Equal("Ada", post["first_name"]);
+ }
+
+ [SkippableFact]
+ public async Task A_phone_box_holding_a_dial_code_is_typed_after_it_and_a_resume_parse_does_not_win()
+ {
+ // Motion Recruitment: fill() into "+1 " left "+1 " (#438). Robert Half: the résumé went up
+ // after the typing and its parse rewrote what had been typed (#438).
+ Skip.IfNot(Available, "Node Playwright is not installed (npm ci)");
+ var packet = new AgentPacket
+ {
+ ApplicationName = "motion-dotnet.md", Provider = "unknown",
+ Questions =
+ [
+ new("firstName", "First Name:", true, PacketQuestion.Text, [], PacketQuestion.Standard),
+ new("phone", "Phone Number:", true, PacketQuestion.Text, [], PacketQuestion.Standard),
+ new("resume", "Upload Your Resume:", true, PacketQuestion.File, [], PacketQuestion.Standard),
+ ],
+ Answers = new() { ["firstName"] = "Ada", ["phone"] = "5712975406" },
+ };
+ var outcome = await Submitter().RunAsync($"{_fixtureUrl}/jobs/phone-widget", packet, (Pdf, "resume.pdf"), dryRun: false);
+ Assert.True(outcome.Submitted, outcome.Error);
+ var post = Assert.Single(_posts);
+ Assert.Equal("+1 5712975406", post["phone"]);
+ Assert.Equal("Ada", post["firstName"]);
+ }
+
+ [Fact]
+ public void A_dial_code_prefix_and_the_national_number_are_read_apart()
+ {
+ Assert.Equal("1", BrowserSubmitter.DialCodePrefix("+1 "));
+ Assert.Equal("44", BrowserSubmitter.DialCodePrefix(" +44"));
+ Assert.Null(BrowserSubmitter.DialCodePrefix(""));
+ Assert.Null(BrowserSubmitter.DialCodePrefix("+1 (571) 297-5406"));
+ Assert.Equal("5712975406", BrowserSubmitter.NationalDigits("5712975406", "1"));
+ Assert.Equal("5712975406", BrowserSubmitter.NationalDigits("+1 571 297 5406", "1"));
+ Assert.Equal("5712975406", BrowserSubmitter.NationalDigits("15712975406", "1"));
+ Assert.Equal("1234567890", BrowserSubmitter.NationalDigits("1234567890", "1"));
+ }
+
+ [SkippableFact]
+ public async Task A_placeholder_that_only_says_enter_does_not_name_the_field()
+ {
+ // Fullstack: every question came back "Enter", optional (#439).
+ Skip.IfNot(Available, "Node Playwright is not installed (npm ci)");
+ var questions = await Discoverer().DiscoverAsync($"{_fixtureUrl}/jobs/enter-placeholders");
+ Assert.NotNull(questions);
+ var phone = Assert.Single(questions!, q => q.Id == "talent.phone");
+ Assert.Equal("Phone", phone.Label);
+ Assert.True(phone.Required);
+ var portfolio = Assert.Single(questions!, q => q.Id == "talent.portfolio");
+ Assert.Equal("Portfolio URL", portfolio.Label);
+ Assert.False(portfolio.Required);
+ }
+}
diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs
index 76c0447..5b90d2c 100644
--- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs
+++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs
@@ -24,7 +24,7 @@ namespace ApplyTrack.Api.Tests;
/// here resolves to loopback and the fixture asserts it, so a stray real URL
/// fails loudly instead of applying to someone's job.
///
-public sealed class BrowserSubmitterTests : IAsyncLifetime
+public sealed partial class BrowserSubmitterTests : IAsyncLifetime
{
private static readonly string RepoRoot = FindRepoRoot();
private static readonly string Cli = Path.Combine(RepoRoot, "node_modules", "playwright-core", "cli.js");
@@ -81,6 +81,20 @@ public async Task InitializeAsync()
"""""",
"""Continue""")
.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("