From 04d8fa6aa1686b66c512332bc8446c94ad7a6c38 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 13 Oct 2026 02:26:05 -0500 Subject: [PATCH 1/2] feat: drive Workday's application wizard end to end (#443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BrowserCanSubmit now accepts Workday, so the agent walks the wizard signed in as the candidate instead of routing the row to copy-and-open. - AtsProvider: WorkdayApplyUrl rewrites a posting link to …/apply/applyManually with the query dropped. - BrowserSession: Workday detection by host and data-automation-id markers; EnterWorkdayAsync (Apply → Apply Manually → Sign in with email); SettleWorkdayAsync waits for the control count to hold still. Sign-in types the credentials key by key, clicks the overlay submit, waits for a real complaint, and reloads until Workday's post-sign-in "Something went wrong" clears. - AccountCreator: registration goes through Sign in with email first. - FormDiscoverer: aria-haspopup=listbox buttons become Select questions; radios take aria-required from their group; ReadChoices opens the menus by name and drops the "Select One" placeholder and chips. - BrowserSubmitter: listbox and selectinput widgets are driven, EEO questions declined, terms ticked, already-answered questions count as mapped, Next is waited on while Workday autosaves, a disabled Next hands back only the starred fields, and FillWorkdaySections repairs the Work Experience / Education rows. - AnswerDrafter: the phone rule is skipped when the question has options. - Tests: WorkdayWizardHtml fixture with a /wd/step recorder; the wizard is walked to hand-back, Review and Submit; env-driven live dry run; the Greenhouse, Submit and Errors endpoint tests expect Workday to be queued and retried rather than handed back. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015Xrsyx9xJYfnT2BzLMNFsB --- SPRINTS.md | 5 +- api/ApplyTrack.Api.Tests/AtsProviderTests.cs | 29 +- .../BrowserSubmitterTests.cs | 272 +++++++++++ .../ErrorsEndpointTests.cs | 9 +- .../FormDiscovererTests.cs | 15 + .../GreenhouseBoardTests.cs | 5 +- .../SubmitEndpointTests.cs | 10 +- api/ApplyTrack.Api/Agent/AnswerDrafter.cs | 4 +- api/ApplyTrack.Api/Agent/AtsProvider.cs | 39 +- .../Agent/Browser/AccountCreator.cs | 12 +- .../Agent/Browser/BrowserSession.cs | 169 ++++++- .../Agent/Browser/BrowserSubmitter.cs | 460 +++++++++++++++++- .../Agent/Browser/FormDiscoverer.cs | 56 ++- 13 files changed, 1046 insertions(+), 39 deletions(-) diff --git a/SPRINTS.md b/SPRINTS.md index cf4b0be..63512d8 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -176,8 +176,9 @@ All four shipped; **v1 is feature-complete** (every `plan.md` build step is done `/application`) get real questions this way; the same `PacketQuestion` shape Greenhouse's API gives, so the drafter and the submitter need no per-ATS code. The generic adapter (fill by label, refuse to click on an unmapped required - field) is behind the per-tenant `long_tail` opt-in (0023). Workday stays - manual, permanently: detected, packet prepared, routed to copy-and-open. + field) is behind the per-tenant `long_tail` opt-in (0023). Workday was routed + to copy-and-open until #443 (v1.68.0): its wizard is now walked signed in as + the candidate, with the account made on the way under #277's opt-in. - ✅ **Step 6 — Encryption at rest, operator allowlist & captcha code fallback** (#117, #156, #165, #166; v1.26–v1.27): Sensitive columns sealed with AES-256-GCM at rest (résumés, cover letters, packet answers, evidence screenshots, tenant API keys, bot tokens). Production audit closed diff --git a/api/ApplyTrack.Api.Tests/AtsProviderTests.cs b/api/ApplyTrack.Api.Tests/AtsProviderTests.cs index 285cf39..18069c9 100644 --- a/api/ApplyTrack.Api.Tests/AtsProviderTests.cs +++ b/api/ApplyTrack.Api.Tests/AtsProviderTests.cs @@ -58,11 +58,12 @@ public void The_apply_url_is_the_forms_page(string link, string expected) => Assert.Equal(expected, AtsProvider.ApplyUrl(link, AtsProvider.Detect(link, ""))); [Fact] - public void The_browser_drives_the_discovered_boards_and_never_an_aggregator_or_workday() + public void The_browser_drives_the_discovered_boards_and_workday_and_never_an_aggregator() { foreach (var p in new[] { AtsProvider.Greenhouse, AtsProvider.Lever, AtsProvider.Ashby, AtsProvider.Workable, AtsProvider.Breezy, AtsProvider.SmartRecruiters, AtsProvider.Join }) Assert.True(AtsProvider.BrowserCanSubmit(p, longTailOptIn: false), p); - Assert.False(AtsProvider.BrowserCanSubmit(AtsProvider.Workday, longTailOptIn: true)); + // Workday's wizard is walked signed in as the candidate (#443). + Assert.True(AtsProvider.BrowserCanSubmit(AtsProvider.Workday, longTailOptIn: false)); Assert.False(AtsProvider.BrowserCanSubmit(AtsProvider.SuccessFactors, longTailOptIn: true)); Assert.False(AtsProvider.BrowserCanSubmit(AtsProvider.Aggregator, longTailOptIn: true)); Assert.False(AtsProvider.BrowserCanSubmit(AtsProvider.Unknown, longTailOptIn: false)); @@ -174,7 +175,7 @@ public void Easy_apply_is_driven_only_by_its_own_switch() Assert.True(AtsProvider.BrowserCanSubmit(AtsProvider.LinkedInEasy, longTailOptIn: false, linkedInEasyOptIn: true)); // And it switches nothing else on. Assert.False(AtsProvider.BrowserCanSubmit(AtsProvider.Aggregator, longTailOptIn: true, linkedInEasyOptIn: true)); - Assert.False(AtsProvider.BrowserCanSubmit(AtsProvider.Workday, longTailOptIn: true, linkedInEasyOptIn: true)); + Assert.False(AtsProvider.BrowserCanSubmit(AtsProvider.SuccessFactors, longTailOptIn: true, linkedInEasyOptIn: true)); } [Fact] @@ -205,4 +206,26 @@ public void A_kept_linkedin_session_becomes_cookies_on_the_whole_site() [InlineData("https://jobs.lever.co/acme/1", null)] public void A_workday_posting_has_a_json_address_that_says_whether_it_still_exists(string link, string? expected) => Assert.Equal(expected, AtsProvider.WorkdayJobApi(link)); + + [Theory] + [InlineData("https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite/job/US-CA-Santa-Clara/Senior-Solutions-Architect--Inference-Service-Providers_JR2023533/apply?source=Eightfold", + "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite/job/US-CA-Santa-Clara/Senior-Solutions-Architect--Inference-Service-Providers_JR2023533/apply/applyManually")] + [InlineData("https://allstate.wd5.myworkdayjobs.com/en-US/allstate_careers/job/USA---IL-Remote/Lead-Net-Software-Engineer_R34186/?src=x", + "https://allstate.wd5.myworkdayjobs.com/en-US/allstate_careers/job/USA---IL-Remote/Lead-Net-Software-Engineer_R34186/apply/applyManually")] + [InlineData("https://strongtie.wd1.myworkdayjobs.com/en-US/External/job/Plano-TX/Senior-Software-Engineer---Cloud-Services_R1", + "https://strongtie.wd1.myworkdayjobs.com/en-US/External/job/Plano-TX/Senior-Software-Engineer---Cloud-Services_R1/apply/applyManually")] + public void A_workday_application_starts_at_the_manual_flow_with_the_query_dropped(string link, string expected) + { + // The manual flow (#443): the sign-in choices, then step one — not the résumé autofill. + Assert.Equal(expected, AtsProvider.WorkdayApplyUrl(link)); + Assert.Equal(expected, AtsProvider.ApplyUrl(link, AtsProvider.Workday)); + } + + [Fact] + public void A_link_that_is_not_a_workday_posting_has_no_manual_flow() + { + Assert.Null(AtsProvider.WorkdayApplyUrl("https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite")); + Assert.Null(AtsProvider.WorkdayApplyUrl("https://boards.greenhouse.io/acme/jobs/1")); + Assert.Equal("https://boards.greenhouse.io/acme/jobs/1", AtsProvider.ApplyUrl("https://boards.greenhouse.io/acme/jobs/1", AtsProvider.Workday)); + } } diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index 5b90d2c..327cce4 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -40,6 +40,9 @@ public sealed partial class BrowserSubmitterTests : IAsyncLifetime private WebApplication? _fixture; private string _fixtureUrl = ""; private readonly Dictionary _wdAccounts = []; + /// What the Workday wizard fixture reported as the run moved through it (#443): each step + /// reached with the answers held so far, and the submission. + private readonly List _wdSteps = []; private string _wdHoneypot = ""; private readonly List> _posts = []; private readonly List> _laterPosts = []; @@ -570,6 +573,18 @@ function suggest(box) { ? Results.Content("

Candidate Home

My Applications

", "text/html") : Results.Content(WorkdaySignInHtml.Replace("", "
Wrong email or password
"), "text/html"); }); + // Workday's application wizard as nvidia.wd5 serves it (#443): the manual flow's sign-in + // choices, the email sign-in under a click_filter overlay, then My Information (a search + // box, a drop-down button, a required radio group), Application Questions (a drop-down), + // Voluntary Disclosures (a demographic drop-down, the terms box) and Review with Submit. + _fixture.MapGet("/wd/en-US/Careers/job/Omaha/Engineer_R1/apply/applyManually", () => Results.Content(WorkdayWizardHtml, "text/html")); + _fixture.MapPost("/wd/step", async (HttpRequest req) => + { + using var reader = new StreamReader(req.Body); + var body = await reader.ReadToEndAsync(); + lock (_wdSteps) _wdSteps.Add(body); + return Results.Ok(); + }); // Oracle Taleo's IAM pages as upmcjobs.taleo.net serves them (#277): Candidate Login with // New User, then New User Registration; the user name is the email. _fixture.MapGet("/careersection/iam/accessmanagement/login.jsf", () => Results.Content(TaleoLoginHtml, "text/html")); @@ -1493,6 +1508,93 @@ Click the object that does not fit the column pattern """; + private const string WorkdayWizardHtml = """ + +
+

Sign In

+ + +
+ + + + + + + + + + """; + private const string WorkdaySignInHtml = """

Sign In

@@ -2847,6 +2949,176 @@ public async Task An_email_step_with_a_terms_box_is_a_sign_in_and_the_box_is_tic private AccountCreator Creator() => new(new BrowserOptions { Endpoint = _ws, AllowPrivateTargets = true, TimeoutSeconds = 60 }, NullLogger.Instance); + private static AgentPacket WorkdayPacket(bool authorized) => new() + { + ApplicationName = "acme-engineer-wd.md", Provider = "workday", + Questions = + [ + new("legalName--firstName", "First Name", true, PacketQuestion.Text, [], PacketQuestion.Standard), + new("legalName--lastName", "Last Name", true, PacketQuestion.Text, [], PacketQuestion.Standard), + new("source--source", "How Did You Hear About Us?", true, PacketQuestion.Text, [], PacketQuestion.Custom), + new("country", "Country", true, PacketQuestion.Select, ["Canada", "United States of America"], PacketQuestion.Custom), + new("candidateIsPreviousWorker", "Have you previously worked for Acme?", true, PacketQuestion.Select, ["Yes", "No"], PacketQuestion.Custom), + .. authorized ? new PacketQuestion[] { new("legallyAuthorized", "Are you legally authorized to work in the country where this position is located?", true, PacketQuestion.Select, ["Yes", "No"], PacketQuestion.Custom) } : [], + ], + Answers = new() + { + ["legalName--firstName"] = "Ada", ["legalName--lastName"] = "Byte", ["source--source"] = "LinkedIn", + ["country"] = "United States", ["candidateIsPreviousWorker"] = "No", ["legallyAuthorized"] = "Yes", + }, + }; + + /// + /// Workday's wizard (#443), walked signed in with the saved account: the email sign-in behind + /// "Sign in with email" and its click_filter overlay; a search box committed with Enter; a + /// drop-down button chosen from its menu; a radio group required on its wrapper. A step + /// whose question the packet never saw is handed back with its choices, so the drafter can + /// answer it; the next run answers it and goes on — the demographic drop-down declined, the + /// terms box ticked — to Review, where a real run presses Submit. + /// + [SkippableFact] + public async Task A_workday_wizard_is_walked_signed_in_and_hands_back_what_it_meets() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var url = $"{_fixtureUrl}/wd/en-US/Careers/job/Omaha/Engineer_R1/apply/applyManually"; + var account = new BoardAccount(new Uri(_fixtureUrl).Host, "ada@example.com", "pw"); + lock (_wdSteps) _wdSteps.Clear(); + + // Run 1: Application Questions asks something the packet has never seen. + var first = await Submitter().RunAsync(url, WorkdayPacket(authorized: false), (Pdf, "resume.pdf"), dryRun: true, accounts: [account]); + Assert.True(first.Filled, first.Error); + Assert.False(first.Submitted); + Assert.Contains("legallyAuthorized", first.Unmapped); + var met = Assert.Single(first.Discovered ?? []); + Assert.Equal("legallyAuthorized", met.Id); + Assert.Equal(PacketQuestion.Select, met.Type); + Assert.Equal(["Yes", "No"], met.Options); + string[] steps; + lock (_wdSteps) steps = [.. _wdSteps]; + var step2 = Assert.Single(steps, s => s.StartsWith("step2 ")); + Assert.Contains("\"source\":[\"Linkedin Jobs\"]", step2); + Assert.Contains("\"previous\":\"false\"", step2); + Assert.Contains("\"country\":\"United States of America\"", step2); + Assert.Contains("\"first\":\"Ada\"", step2); + Assert.DoesNotContain(steps, s => s.StartsWith("step3 ")); + + // Run 2: answered, the walk reaches Review — ethnicity declined, the terms ticked — and a dry run stops there. + lock (_wdSteps) _wdSteps.Clear(); + var second = await Submitter().RunAsync(url, WorkdayPacket(authorized: true), (Pdf, "resume.pdf"), dryRun: true, accounts: [account]); + Assert.True(second.Filled, second.Error); + Assert.False(second.Submitted); + Assert.Empty(second.Unmapped); + Assert.Null(second.Discovered); + lock (_wdSteps) steps = [.. _wdSteps]; + var review = Assert.Single(steps, s => s.StartsWith("step4 ")); + Assert.Contains("\"authorized\":\"Yes\"", review); + Assert.Contains("\"ethnicity\":\"Decline to State (United States of America)\"", review); + Assert.Contains("\"terms\":true", review); + Assert.DoesNotContain(steps, s => s.StartsWith("submitted ")); + + // Run 3: for real — Submit is pressed on Review and Workday's confirmation read. + lock (_wdSteps) _wdSteps.Clear(); + var real = await Submitter().RunAsync(url, WorkdayPacket(authorized: true), (Pdf, "resume.pdf"), dryRun: false, accounts: [account]); + Assert.True(real.Submitted, real.Error); + Assert.Contains("successfully submitted", real.Confirmation, StringComparison.OrdinalIgnoreCase); + lock (_wdSteps) steps = [.. _wdSteps]; + Assert.Contains(steps, s => s.StartsWith("submitted ")); + } + + /// + /// The live check for Workday (#443): set APPLYTRACK_LIVE_WORKDAY_URL to a posting and + /// APPLYTRACK_LIVE_WORKDAY_USER / _PASSWORD to a candidate account on that tenant, + /// and the wizard is discovered and dry-run exactly as the worker does it — signed in, step + /// by step, never past Submit. Workday saves each step as a draft under the account, which + /// is why this cannot run with POSTs blocked and why it only ever dry-runs. What it stopped + /// on, and what it handed back, is printed. Never runs in CI. + /// + [SkippableFact] + public async Task Live_workday_wizard_is_walked_to_review_as_a_dry_run() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var url = Environment.GetEnvironmentVariable("APPLYTRACK_LIVE_WORKDAY_URL") ?? ""; + var user = Environment.GetEnvironmentVariable("APPLYTRACK_LIVE_WORKDAY_USER") ?? ""; + var password = Environment.GetEnvironmentVariable("APPLYTRACK_LIVE_WORKDAY_PASSWORD") ?? ""; + Skip.If(url.Length == 0 || user.Length == 0 || password.Length == 0, "set APPLYTRACK_LIVE_WORKDAY_URL/USER/PASSWORD to run this"); + var options = new BrowserOptions { Endpoint = _ws, Proxy = LiveProxy, TimeoutSeconds = 180 }; + var accounts = new[] { new BoardAccount(new Uri(url).Host, user, password) }; + var apply = ApplyTrack.Api.Agent.AtsProvider.ApplyUrl(url, ApplyTrack.Api.Agent.AtsProvider.Workday); + var questions = await new FormDiscoverer(options, new LiveLogger()).DiscoverAsync(apply, accounts: accounts); + Assert.NotNull(questions); + Console.WriteLine($"LIVE QUESTIONS: {string.Join(" | ", questions!.Select(q => $"{q.Id}={q.Label}[{q.Type}{(q.Required ? "*" : "")}{(q.Options.Count > 0 ? ":" + string.Join("/", q.Options) : "")}]"))}"); + var resume = new Resume + { + FullName = "Aaron K. Clark", Location = "Minden, Nebraska", + Experience = [new ResumeExperience("Ronin 48", "Principal Software Architect & ML Engineer", "Mar 2026 - Present", ["Designing overall system architecture for an AI startup."])], + Education = [new ResumeEducation("Eastern University", "MS", "Applied Artificial Intelligence", "2026 - Present")], + }; + var packet = new AgentPacket { ApplicationName = "live-wd.md", Provider = ApplyTrack.Api.Agent.AtsProvider.Workday, Questions = questions, Resume = resume }; + var ctx = new ApplyTrack.Api.Agent.AnswerContext(resume, + new AgentSettings { Phone = "5712975406", WorkAuthorization = "US citizen", Country = "United States", SalaryExpectation = "125000" }, + user, "", ""); + foreach (var q in packet.Questions.Where(q => q.Kind != PacketQuestion.Eeo && q.Type != PacketQuestion.File)) + { + var (answer, _) = ApplyTrack.Api.Agent.AnswerDrafter.Deterministic(q, ctx); + packet.Answers[q.Id] = answer ?? q.Id switch + { + "source--source" => "LinkedIn", + "legalName--firstName" => "Aaron", "legalName--lastName" => "Clark", + "addressLine1" => "PO Box 28", "city" => "Minden", "countryRegion" => "Nebraska", "postalCode" => "68959", + "phoneType" => "Home Cellular", "phoneNumber" => "5712975406", "candidateIsPreviousWorker" => "No", + _ => q.Options.Count > 0 ? q.Options[0] : q.Type == PacketQuestion.Textarea ? "Stand-in text." : "n/a", + }; + } + // The questions later steps hand back get the standing answers a drafter would give. + var submitter = new BrowserSubmitter(options, new LiveLogger()); + for (var round = 1; round <= 3; round++) + { + var dry = await submitter.RunAsync(url, packet, (Pdf, "resume.pdf"), dryRun: true, resumeText: "Aaron K. Clark.", accounts: accounts); + Console.WriteLine($"LIVE DRY {round}: filled={dry.Filled} url={dry.Url} mapped=[{string.Join(", ", dry.Mapped)}] unmapped=[{string.Join(", ", dry.Unmapped)}] error={dry.Error}"); + if (dry.Screenshot is { } png) await File.WriteAllBytesAsync(Path.Combine(Path.GetTempPath(), $"live-workday-dry{round}.png"), png); + if (dry.Discovered is not { Count: > 0 } met) break; + Console.WriteLine($"LIVE MET {round}: {string.Join(" | ", met.Select(q => $"{q.Id}={q.Label}[{q.Type}{(q.Required ? "*" : "")}{(q.Options.Count > 0 ? ":" + string.Join("/", q.Options) : "")}]"))}"); + foreach (var q in met) + { + if (packet.Questions.Any(p => p.Id == q.Id)) continue; + packet.Questions.Add(q); + var (answer, _) = ApplyTrack.Api.Agent.AnswerDrafter.Deterministic(q, ctx); + if (q.Kind == PacketQuestion.Eeo || q.Type == PacketQuestion.File) continue; + var standIn = q.Id switch + { + "linkedInAccount" => "https://www.linkedin.com/in/aaronkclark", + "jobTitle" => "Principal Software Architect & ML Engineer", "companyName" => "Ronin 48", "location" => "Remote", + "currentlyWorkHere" => "Yes", "schoolName" => "Eastern University", "degree" => "Masters", + _ when q.Options.Count > 0 => q.Options[0], + _ when System.Text.RegularExpressions.Regex.IsMatch(q.Id + " " + q.Label, @"date|year|month|gpa|grade", System.Text.RegularExpressions.RegexOptions.IgnoreCase) => "", + _ => "n/a", + }; + var chosen = answer ?? standIn; + if (chosen.Length > 0) packet.Answers[q.Id] = chosen; + } + } + } + + /// What a live run says as it goes, on the console, prefixed so it can be grepped. + private sealed class LiveLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Console.WriteLine($"LIVE LOG [{logLevel}] {formatter(state, exception)}"); + } + + [SkippableFact] + public async Task A_workday_posting_with_no_saved_account_names_the_host_that_needs_one() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var url = $"{_fixtureUrl}/wd/en-US/Careers/job/Omaha/Engineer_R1/apply/applyManually"; + var outcome = await Submitter().RunAsync(url, WorkdayPacket(authorized: true), (Pdf, "resume.pdf"), dryRun: true); + Assert.False(outcome.Filled); + Assert.Equal(new Uri(_fixtureUrl).Host, outcome.NeedsAccountAt); + Assert.Contains("no account is saved", outcome.Error); + } + [SkippableFact] public async Task A_workday_account_is_created_verified_from_the_mailbox_and_proved_by_signing_in_before_it_is_kept() { diff --git a/api/ApplyTrack.Api.Tests/ErrorsEndpointTests.cs b/api/ApplyTrack.Api.Tests/ErrorsEndpointTests.cs index 251370d..0a8260a 100644 --- a/api/ApplyTrack.Api.Tests/ErrorsEndpointTests.cs +++ b/api/ApplyTrack.Api.Tests/ErrorsEndpointTests.cs @@ -121,13 +121,14 @@ await conn.ExecuteAsync( Assert.True(rows[captcha].GetProperty("captcha").GetBoolean()); Assert.Equal("you", rows[clicked].GetProperty("next").GetString()); Assert.Contains("may have gone through", rows[clicked].GetProperty("why").GetString()); - Assert.Equal("you", rows[workday].GetProperty("next").GetString()); - Assert.Contains("Workday", rows[workday].GetProperty("why").GetString()); + // Workday is driven since #443: a transient failure there is retried like any other board's. + Assert.Equal("retry", rows[workday].GetProperty("next").GetString()); + Assert.Contains("attempt", rows[workday].GetProperty("why").GetString()); Assert.Equal("you", rows[givenUp].GetProperty("next").GetString()); Assert.Contains("gave up after 3 failed runs", rows[givenUp].GetProperty("why").GetString()); - Assert.Equal(1, body.GetProperty("retrying").GetInt32()); - Assert.Equal(4, body.GetProperty("needs_you").GetInt32()); + Assert.Equal(2, body.GetProperty("retrying").GetInt32()); + Assert.Equal(3, body.GetProperty("needs_you").GetInt32()); } /// The view with the client holding . diff --git a/api/ApplyTrack.Api.Tests/FormDiscovererTests.cs b/api/ApplyTrack.Api.Tests/FormDiscovererTests.cs index c4b1dd1..19e88ea 100644 --- a/api/ApplyTrack.Api.Tests/FormDiscovererTests.cs +++ b/api/ApplyTrack.Api.Tests/FormDiscovererTests.cs @@ -283,6 +283,21 @@ public async Task A_form_collapsed_behind_apply_now_is_expanded_so_every_field_i Assert.Equal(0, _posts); } + [Fact] + public void A_workday_drop_down_button_is_a_select_keyed_by_its_name() + { + //