diff --git a/.env.production.example b/.env.production.example index 4c02d03..8225af8 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.68.4 +OSAPPLYTRACK_VERSION=1.68.5 POSTGRES_USER=applytrack POSTGRES_PASSWORD=CHANGE-ME-use-a-long-random-password POSTGRES_DB=applytrack diff --git a/BACKLOG.md b/BACKLOG.md index 337c27e..206d10e 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -180,6 +180,7 @@ review items and unsupported forms still need human attention. - [x] [#448 — Account creator glances for Workday's Apply instead of waiting: finastra.wd3 gets "no Create Account form after Apply"](https://github.com/CryptoJones/OSApplyTrack/issues/448) (the first Workday run after #446, finastra.wd3 on 2026-10-13: `RegisterWorkdayAsync` pressed Apply and Apply Manually with a glance the instant the posting's HTML landed, and Workday draws Apply only once its script has run — ~8 s there — so nothing was pressed and no form came; step 1 of the flow is fetched after the flow page is drawn too, blank for ~7 s. **1.68.2:** each step of the way in is waited for — Apply, Apply Manually, then whichever of Create Account, Sign In or "Sign in with email" the tenant shows, with 30 s for the lazy step — and the fixture draws Apply and the form late so a glance fails the test.) - [x] [#450 — Workday tenants that open step 1 on Create Account: the saved password is typed into Create Account and the sign-in is judged refused (finastra.wd3)](https://github.com/CryptoJones/OSApplyTrack/issues/450) (after #448 the creator made, proved and saved a finastra.wd3 account, and the re-run was "refused": Finastra's step 1 opens on Create Account — the same email and password boxes as Sign In plus Verify New Password and a "Sign In" link — so the saved account was typed into Create Account and the link pressed as the sign-in button, flipping the form empty. **1.68.3:** the way in waits up to 30 s for whatever step 1 shows, turns a Create Account over to Sign In through its link before any typing, and the same guard runs where the walk's own sign-ins start; a fixture variant that opens on Create Account, drawn late, signs in through the link and reaches Review.) - [x] [#453 — Workday My Information hands back the same five fields every round (finastra.wd3)](https://github.com/CryptoJones/OSApplyTrack/issues/453) (on 1.68.3 the run signed in and filled the page, then handed back résumé, email, phone type, city and "How Did You Hear About Us?" on every round. Four causes, replayed from makemake: Workday's search box says "Search" in its placeholder and discovery read it as a site search and dropped it, while the sweep named it anyway; the sweep keyed a box by id and discovery by name, so City was unmapped under `address--city` and never drafted under `city`; Phone Device Type was discovered before its menu was read and the phone rule drafted the number; the email is drawn as text with no box. **1.68.4:** a Workday search box is a question and is typed into key by key before Enter; the sweep keys name then id as discovery does; an email already shown on the page is answered; the drafter answers the kind of phone (Mobile), where the posting was seen (LinkedIn, or the job-board option) instead of leaving it to the person, and the city and state of the résumé's location as their own boxes, with US abbreviations expanded for a fixed list.) +- [x] [#455 — Workday My Experience: the filler counts the saved rows before they are drawn and adds blank ones (finastra.wd3)](https://github.com/CryptoJones/OSApplyTrack/issues/455) (on 1.68.4 the first visit to My Experience filled three jobs and four schools; every visit after pressed Add Another again and handed back the blank rows, because a saved draft draws the block and its Add button half a second before its rows — more over pluto's proxy — and the filler counted none. Racing the late rows also put the wrong school and field on two education rows, and the search box took the first option ("Accounting") when nothing read as the field. **1.68.5:** a block's rows are waited for until their count has held still; held education rows are completed, never added to, as jobs already were; a search box with no match is left empty unless it is required; a date spinbutton that still disagrees after typing is cleared and typed once more.) - [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) diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index 6872948..ad4dfbe 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -583,6 +583,8 @@ function suggest(box) { _fixture.MapGet("/wd/en-US/Careers/job/Omaha/Engineer_R2/apply/applyManually", () => Results.Content(WorkdayWizardCreateFirstHtml, "text/html")); // Workday's search box beside a real site search (#453): the box is a question, the search is not. _fixture.MapGet("/jobs/workday-search-box", () => Results.Content(WorkdaySearchBoxHtml, "text/html")); + // Workday's My Experience as a saved draft draws it (#455): the blocks and their Add buttons first, the held rows 1.5 s on. + _fixture.MapGet("/wd/experience", () => Results.Content(WorkdayExperienceHtml, "text/html")); _fixture.MapPost("/wd/step", async (HttpRequest req) => { using var reader = new StreamReader(req.Body); @@ -1515,6 +1517,38 @@ Click the object that does not fit the column pattern """; + private const string WorkdayExperienceHtml = """ +
+

Work Experience

+
+

Education

+
+ + +
+ + """; + private const string WorkdaySearchBoxHtml = """
@@ -3195,6 +3229,45 @@ public async Task A_workday_search_box_is_discovered_as_a_question_and_the_site_ Assert.DoesNotContain(questions!, q => q.Id == "q" || q.Label.Contains("Search for Jobs")); } + /// A saved draft's rows arrive after the block and its Add button (#455): counted at + /// once, the filler added a blank row per job on finastra.wd3. Held rows are waited for and + /// completed; only the jobs and schools the draft lacks are added. + [SkippableFact] + public async Task Workday_experience_rows_drawn_late_are_completed_not_duplicated() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var resume = new Resume + { + Experience = + [ + new("Ronin 48", "Principal Software Architect", "Mar 2026 - Present", ["Designing the platform."]), + new("CrowdStrike", "Senior Technical Support Engineer", "Aug 2022 - Mar 2024", []), + ], + Education = [new("Eastern University", "MS", "Applied AI", "2026 – Present"), new("University of Maryland University College", "Bachelor of Science", "CIS", "")], + }; + var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.ConnectAsync(_ws); + var page = await browser.NewPageAsync(); + await page.GotoAsync($"{_fixtureUrl}/wd/experience"); + + var filled = await BrowserSubmitter.FillWorkdaySectionsAsync(page, resume); + + Assert.Equal(2, await page.GetByRole(Microsoft.Playwright.AriaRole.Group, new() { NameRegex = new System.Text.RegularExpressions.Regex(@"^Work Experience \d+$") }).CountAsync()); + Assert.Equal("Principal Software Architect", await page.Locator("#we1t").InputValueAsync()); // held, untouched + Assert.Equal("3", await page.Locator("#we1m").InputValueAsync()); + Assert.True(await page.Locator("#we1h").IsCheckedAsync()); + Assert.Equal("CrowdStrike", await page.Locator("#we2c").InputValueAsync()); + Assert.Equal("8", await page.Locator("#we2m").InputValueAsync()); + Assert.Equal(2, await page.GetByRole(Microsoft.Playwright.AriaRole.Group, new() { NameRegex = new System.Text.RegularExpressions.Regex(@"^Education \d+$") }).CountAsync()); + Assert.Equal("Masters", await page.Locator("#ed1g").InnerTextAsync()); // the held school's blank degree + Assert.Equal("University of Maryland University College", await page.Locator("#ed2s").InputValueAsync()); + Assert.Equal("Bachelors", await page.Locator("#ed2g").InnerTextAsync()); + Assert.Contains("workExperience-1--companyName", filled); + Assert.Contains("education-0--degree", filled); + Assert.DoesNotContain("workExperience-0--jobTitle", filled); + playwright.Dispose(); + } + [SkippableFact] public async Task A_workday_posting_with_no_saved_account_names_the_host_that_needs_one() { diff --git a/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs b/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs index 3e93399..ff36381 100644 --- a/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs +++ b/api/ApplyTrack.Api/Agent/Browser/BrowserSubmitter.cs @@ -1028,7 +1028,14 @@ private static async Task FillSelectInputAsync(IFrame page, ILocator box, var pick = real.FirstOrDefault(x => x.t.Equals(want, StringComparison.OrdinalIgnoreCase), (t: "", n: -1)); if (pick.n < 0) pick = real.Where(x => x.t.StartsWith(want, StringComparison.OrdinalIgnoreCase)).OrderBy(x => x.t.Length).FirstOrDefault((t: "", n: -1)); if (pick.n < 0) pick = real.FirstOrDefault(x => x.t.Contains(want, StringComparison.OrdinalIgnoreCase), (t: "", n: -1)); - if (pick.n < 0) pick = real[0]; + // Nothing reads as the answer: a box that must have one takes the first, any other + // is left empty — Field of Study took "Accounting" for "Applied Artificial + // Intelligence" (#455). + if (pick.n < 0) + { + if (await box.GetAttributeAsync("aria-required") != "true") { await page.Page.Keyboard.PressAsync("Escape"); return false; } + pick = real[0]; + } await options.Nth(pick.n).ClickAsync(new() { Timeout = 5_000 }); await page.WaitForTimeoutAsync(500); return (await SelectInputChipsAsync(box)).Length > 0; @@ -3677,6 +3684,10 @@ public static async Task> FillWorkdaySectionsAsync(IPage page, Resu if (await work.CountAsync() > 0) { var jobs = resume.Experience.Where(e => e.Company.Length > 0 && e.Title.Length > 0).Take(3).ToList(); + // A saved draft's rows arrive after the block and its Add button are drawn (half a + // second on finastra.wd3, more over a proxy): counted at once, there were none, and + // a blank row was added for every job the draft already held (#455). + await WaitRowsSettledAsync(page, rows); var held = await rows.CountAsync(); // Rows already there (a draft resumed) are completed, not added to: each is matched // to the résumé's job by title, else taken in order, and only its blanks are filled. @@ -3708,21 +3719,27 @@ public static async Task> FillWorkdaySectionsAsync(IPage page, Resu var school = page.GetByRole(AriaRole.Group, new() { Name = "Education", Exact = true }); var schools = page.GetByRole(AriaRole.Group, new() { NameRegex = new Regex(@"^Education \d+$") }); - if (await school.CountAsync() > 0 && await schools.CountAsync() == 0) + if (await school.CountAsync() > 0) { - foreach (var (e, n) in resume.Education.Where(e => e.School.Length > 0).Select((e, n) => (e, n))) + await WaitRowsSettledAsync(page, schools); + var wanted = resume.Education.Where(e => e.School.Length > 0).ToList(); + var held = await schools.CountAsync(); + // Rows already there are completed, never added to, the way the jobs are (#455). + for (var i = 0; i < held; i++) + { + var row = schools.Nth(i); + var nameBox = row.GetByRole(AriaRole.Textbox, new() { Name = "School or University" }); + if (await nameBox.CountAsync() == 0) continue; + var name = (await nameBox.First.InputValueAsync()).Trim(); + var e = wanted.FirstOrDefault(w => w.School.Equals(name, StringComparison.OrdinalIgnoreCase)) ?? wanted.ElementAtOrDefault(i); + if (e is null) continue; + await FillWorkdaySchoolAsync(page, row, e, i, filled); + } + foreach (var (e, n) in wanted.Select((e, n) => (e, n)).Skip(held)) { if (!await AddWorkdayRowAsync(page, school)) break; var row = schools.Last; - await FillBlankAsync(row.GetByRole(AriaRole.Textbox, new() { Name = "School or University" }), e.School, $"education-{n}--school", filled); - var degree = row.GetByRole(AriaRole.Button, new() { NameRegex = new Regex(@"^Degree\b") }).First; - if (WorkdayDegree(e.Degree) is { } term && await degree.CountAsync() > 0 - && await ChooseListboxAsync(page.MainFrame, degree, new($"education-{n}--degree", "Degree", false, PacketQuestion.Select, [], PacketQuestion.Custom), term)) - filled.Add($"education-{n}--degree"); - var field = row.GetByRole(AriaRole.Textbox, new() { Name = "Field of Study" }).First; - if (e.Field.Length > 0 && await field.CountAsync() > 0 && await FillSelectInputAsync(page.MainFrame, field, e.Field)) - filled.Add($"education-{n}--fieldOfStudy"); - await FillWorkdayDatesAsync(row, e.Dates, $"education-{n}", filled, months: false); + await FillWorkdaySchoolAsync(page, row, e, n, filled); } } } @@ -3734,6 +3751,35 @@ public static async Task> FillWorkdaySectionsAsync(IPage page, Resu return filled; } + /// One Education row's blanks from the résumé: the school, the Degree menu, the Field of Study search box, the years. + private static async Task FillWorkdaySchoolAsync(IPage page, ILocator row, ResumeEducation e, int n, List filled) + { + await FillBlankAsync(row.GetByRole(AriaRole.Textbox, new() { Name = "School or University" }), e.School, $"education-{n}--school", filled); + var degree = row.GetByRole(AriaRole.Button, new() { NameRegex = new Regex(@"^Degree\b") }).First; + if (WorkdayDegree(e.Degree) is { } term && await degree.CountAsync() > 0 && Placeholder().IsMatch((await degree.InnerTextAsync()).Trim()) + && await ChooseListboxAsync(page.MainFrame, degree, new($"education-{n}--degree", "Degree", false, PacketQuestion.Select, [], PacketQuestion.Custom), term)) + filled.Add($"education-{n}--degree"); + var field = row.GetByRole(AriaRole.Textbox, new() { Name = "Field of Study" }).First; + if (e.Field.Length > 0 && await field.CountAsync() > 0 && await FillSelectInputAsync(page.MainFrame, field, e.Field)) + filled.Add($"education-{n}--fieldOfStudy"); + await FillWorkdayDatesAsync(row, e.Dates, $"education-{n}", filled, months: false); + } + + /// Wait for a block's rows to finish arriving: the count has held still for 3 s, or 15 s have passed (#455). + private static async Task WaitRowsSettledAsync(IPage page, ILocator rows) + { + var last = -1; + var stillSince = DateTime.UtcNow; + var deadline = DateTime.UtcNow.AddSeconds(15); + while (DateTime.UtcNow < deadline) + { + var now = await rows.CountAsync(); + if (now != last) { last = now; stillSince = DateTime.UtcNow; } + else if (DateTime.UtcNow - stillSince >= TimeSpan.FromSeconds(3)) return; + await page.WaitForTimeoutAsync(500); + } + } + /// The block's Add — "Add Another" once a row exists — pressed; false when there is none. private static async Task AddWorkdayRowAsync(IPage page, ILocator block) { @@ -3781,6 +3827,11 @@ private static async Task FillSpinAsync(ILocator spin, string value) if ((await box.InputValueAsync()).Trim().TrimStart('0') == value.TrimStart('0')) return true; await box.ClickAsync(new() { Timeout = 3_000 }); await box.PressSequentiallyAsync(value, new() { Delay = 40 }); + if ((await box.InputValueAsync()).Trim().TrimStart('0') == value.TrimStart('0')) return true; + // Once more over whatever it holds: a month typed into a box that already had one read as + // the two run together (#455). + await box.PressAsync("ControlOrMeta+a"); + await box.PressSequentiallyAsync(value, new() { Delay = 40 }); return (await box.InputValueAsync()).Trim().TrimStart('0') == value.TrimStart('0'); } diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index b3212a5..ccb74e3 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.68.4 + 1.68.5 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/pyproject.toml b/pyproject.toml index 20d3a8f..14332fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.68.4" +version = "1.68.5" description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/src/applytrack/__init__.py b/src/applytrack/__init__.py index d0c591f..63b7ea7 100644 --- a/src/applytrack/__init__.py +++ b/src/applytrack/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """applytrack — a local, markdown-backed job-application tracker.""" -__version__ = "1.68.4" +__version__ = "1.68.5" diff --git a/uv.lock b/uv.lock index 4d0affa..39be68a 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.68.4" +version = "1.68.5" source = { editable = "." } dependencies = [ { name = "cryptography" },