Scraper automation learnings — ADP Workforce Now / ADP Recruiting (technical)¶
Plain-language version: ../plain/scraper-automation-learnings-adp.md
This documents what the local, offline ADP scraper lab taught us about automating
ADP Workforce Now / ADP Recruiting candidate-portal apply flows, and how an AdpAutomationEngine
adapter is hardened against them. It is the sibling of the Workday/UKG/Paycom labs, built for a
fourth ATS family whose markup conventions — a genuine Angular SPA — differ from all three.
Everything in the lab is local and offline. The harness drives a loopback HttpListener
fixture on 127.0.0.1. There is no real network, no real workforcenow.adp.com/myjobs.adp.com
site, no account is ever created (the "Create myADP account" step is a purely local
simulation with no external identity call), and no real submit endpoint. The final "Submit" is a
no-op that logs would submit (suppressed) and restarts with a new randomized variation.
Where the lab lives¶
tools/WorkWingman.ScraperLab.Adp/
Program.cs CLI entry (--iterations N | --minutes M | --out PATH)
FakeAdpSite.cs loopback HttpListener serving the ADP apply flow (8 steps)
SiteVariation.cs per-iteration randomization (anchors, labels, missing fields, slow loads)
SelectorChain.cs priority fallback chain + hit/miss telemetry
AdpAutomationEngine.cs Playwright adapter that fills each step from an IntakeProfile
LoopRunner.cs the loop runner: JSONL log + selector tallies + failure modes
SampleProfile.cs the Andrew Jones seed (reuses Core IntakeProfile shapes)
tools/WorkWingman.ScraperLab.Adp.Tests/
FakeAdpSiteTests.cs fixture + detection + safety tests
SelectorLogicTests.cs selector-chain + variation-randomization tests
Also touched (additive only, reused by every ATS lab): AtsKind.Adp was added to
src/WorkWingman.Core/Models/JobPosting.cs, and a workforcenow.adp.com / myjobs.adp.com
detection branch was added to src/WorkWingman.Infrastructure/Automation/AtsDetector.cs —
mirroring how Workday/Greenhouse/Lever/iCIMS/UKG/Paycom are already recognized there.
How ADP differs from Workday/UKG/Paycom (the core lesson)¶
The Workday adapter leans on data-automation-id, a stable semantic attribute stamped on
essentially every control. Paycom and UKG have no such attribute and fall back to plain
id/name and aria-label respectively. ADP is different again: it is a genuine Angular
single-page application, and its reactive-forms controls commonly expose a
formcontrolname attribute — the Angular binding key that ties a DOM control to its
FormControl in the component class. From public knowledge of the ADP candidate portal
(https://workforcenow.adp.com/mascsr/default/mdf/recruitment/recruitment.html?cid=...&ccId=...
and the newer https://myjobs.adp.com/... shape):
| Concern | Workday | UKG Pro Recruiting | Paycom ATS | ADP Workforce Now / Recruiting |
|---|---|---|---|---|
| Primary stable hook | data-automation-id on every control |
none (leans on ARIA) | none — plain id/name only |
formcontrolname (Angular reactive-forms binding key) |
Auto-generated id durability |
n/a — data-automation-id used instead |
secondary, fairly stable | fairly stable, semantic | least durable of the four — framework-assigned, can shift between renders |
| Accessibility anchors | present but secondary | widely stamped | present but inconsistent | present but inconsistent — similar to Paycom, never primary |
| Framework | Workday's own SPA | React/Angular, client-side routing | mostly server-rendered, light hydration | genuine Angular SPA — full client-side routing + reactive forms |
| Candidate account | required | required | required (self-service portal) | required — "myADP" candidate account (simulated locally here, never real) |
| Voluntary EEO | inline step | collapsed accordion | step OR folded inline at Review, per tenant | "Voluntary Disclosures" — step OR folded inline at Review, per tenant (same structural shape as Paycom, independently modeled) |
| Resume vs manual | resume-optional | resume-upload-first common | resume-upload-first common | resume-parse-first is the ADP default, more aggressively nudged than Paycom/UKG |
| Custom questions | fixed step schema | fixed step schema | fully tenant-defined | "Additional Questions" — fully tenant-defined per requisition, same shape as Paycom |
| URL identity | tenant subdomain | tenant subdomain | single clientkey= query param |
two-part cid= (company id) + ccId= (client company code) query pair |
The practical consequences for the adapter:
- Lead with
formcontrolname, notid. This is the opposite priority from every other lab in this family (Workday leads with its automation id; Paycom leads with plainid). ADP'sformcontrolnamereflects the AngularFormControlbinding and survives re-renders that reshuffle the DOM's auto-generatedids; leading withidhere would have been the wrong lesson to carry over from Paycom. - The Voluntary Disclosures step's location is itself a variable, not just its content — exactly the Paycom lesson, independently reproduced: some tenants render it as its own step, others fold it into the tail of Review. The adapter must detect where it is, not just fill it.
- Additional Questions have no fixed schema. Same as Paycom: the adapter cannot hard-code "salary" / "start date" fields; it must probe for whichever questions a given requisition happens to render.
- Two-part tenant identity (
cid+ccId) means detection and telemetry track both — a singleclientkey-style token (as Paycom uses) is not enough to reconstruct a realistic ADP apply URL.
The selector fallback chain (adapter approach)¶
SelectorChain tries candidates in priority order, waiting for the first visible match. The
priority order encodes the ADP lesson — the inverse of Paycom's and a new tier ahead of Workday's:
[formcontrolname='...']— the Angular reactive-forms binding key. ADP's most stable anchor when present, and it is present on every control in this fixture because it is what actually drives the binding — a real ADP tenant customization is far less likely to strip it than to strip an auto-generatedid.#id— auto-generated id. Demoted to 2nd tier here (unlike Paycom, where plainidled) because ADP'sidvalues are framework-assigned and the least durable of the four labs.[name='...']— the form field name; the workhorse fallback once again, same as every other lab in this family.- label-sibling (
label:has-text('…') + input) — visible-label association. [aria-label='…']— a weaker signal on ADP, similar to Paycom; still occasionally the only anchor left standing.[placeholder='…']— last-resort, brittle.
Two structural refinements matter for ADP specifically:
- Short probe on lead candidates, full budget on the last — identical timing discipline to the UKG/Paycom labs: ~400ms probes on early candidates, ~1.5s on the last (which may be waiting on the personal-info step's Angular route-resolver hydration).
- Detecting where the Voluntary Disclosures step lives, not just its fields. ADP's fixture reuses the exact same page-detection discipline the Paycom lab had to learn the hard way (see the failure-mode table below) — carried forward correctly from day one this time, because the Paycom lab's fix generalized cleanly.
What the loop measured (validation run, --iterations 20)¶
A clean 20-iteration run produced 20/20 completed, 0 failures, every iteration detected as
AtsKind.Adp, every one submit-suppressed, and no field's whole fallback chain went unresolved
(MissedFields was empty on every iteration). Selector telemetry highlights:
[formcontrolname='continue']— the shared "Continue" button binding across every step — was the single highest-hit selector (87 hits in one run), confirmingformcontrolnameas ADP's most reliable anchor, the same role[name='continue']played for UKG/Paycom.- Every
[formcontrolname='firstName']-style selector showed hits alongside misses on iterations where a different attribute was the iteration'sPrimaryAnchor(e.g.anchor=name,anchor=aria) — these are expected probe attempts on a non-primary tier, not full-chain misses; every one of those iterations still resolved the field via[name='firstName']or another later tier. This is the fallback chain working exactly as designed, and it is why "per-selector hit/miss" and "whole-chain miss" are tracked as two separate numbers — conflating them would make normal fallback exercising look like a defect. #id-style selectors showed a roughly 55/45 hit/miss split, matching the ~45%DropStableIdsprobability built intoSiteVariation(ADP's highest of the four labs, reflecting that its auto-generated id is the least durable anchor) — every one of those misses fell through cleanly toformcontrolnameorname.- Tenant Additional-Question selectors (
#aq_*/[formcontrolname='customQ*']) only appear in the ~60% of iterations whereHasCustomQuestionsis true — the adapter's generic-probe approach handled the other 40% (no questions rendered) without any special-casing, same as Paycom. - Resume-parse-first was the majority path (
ResumeUploadVariant~55% probability, ADP's highest of the labs modeled) and the "Enter manually" fallback fired cleanly every time it was offered.
Failure modes anticipated and pre-empted (carried forward from the Paycom lab)¶
Unlike the Paycom lab — which discovered its EEO-page-detection bug live during its own validation
runs — the ADP lab's FillVoluntaryEeoAsync probe was written with the Paycom fix already
applied, because the underlying shape (a shared gender/formcontrolname='gender' control
appearing both on a dedicated Voluntary Disclosures step and folded inline at Review) is
structurally identical:
| # | Anticipated symptom (if the Paycom lesson were ignored) | Root cause it would reproduce | Pre-emptive fix applied from day one |
|---|---|---|---|
| 1 | Probing #eeoGender, [formcontrolname='gender'] to decide "is Voluntary Disclosures on its own step?" would misfire |
Review's inline fold-in also renders a control bound to gender (by design, so the two code paths share a mapping function) — the probe cannot tell the two apart on gender alone |
Probe on an anchor that exists only on the dedicated step (veteranStatus/disabilityStatus), never the shared gender binding |
| 2 | An id-only version of fix #1 would misroute whenever DropStableIds=true removed the dedicated step's ids |
The probe would wrongly conclude "folded inline," skip the real Voluntary Disclosures step, and leave Playwright stranded when Review's submit-locator ran | Probe using both the id and the formcontrolname/name form of veteran/disability, so the check survives DropStableIds the same way every other field's fallback chain does |
The 20/20 clean run with zero missed fields and zero page-detection failures confirms the
pre-emptive fix holds. The lesson generalizes across ATS families: any "which branch of the
flow am I on?" probe is itself a selector-chain problem — it needs the same
formcontrolname→id→name fallback discipline as an ordinary field, not a single hard-coded
anchor, and this discipline transfers cleanly to a brand-new markup family (Angular vs. Paycom's
mostly-server-rendered DOM) as long as the underlying shape of the ambiguity is the same.
Judgement calls (ambiguity, not guessing)¶
Mirroring the Workday/UKG/Paycom "degree" case, the education step raises a judgement call
every iteration: the profile carries Degree = "Bachelor of Science" and
FieldOfStudy = "Game Development" separately, but some ADP tenants render a single free-text
degree box that expects a combined "B.S. in Game Development". The adapter flags this
(AmbiguousFields, logged JUDGEMENT — degree format ambiguous) rather than silently picking a
format.
Tenant-defined Additional Questions are a second, structural source of ambiguity: because there is
no fixed schema, the adapter maps its three synthetic fixture questions from direct
IntakeProfile fields (earliest start date, salary expectation, relocation preference) when
present, and simply skips the step when the fixture renders none — it never invents an answer to
a question shape it doesn't recognize.
Safety model (identical discipline to the Workday/UKG/Paycom labs)¶
- No real network. The fixture is loopback-only (
http://127.0.0.1:<port>/), and itsApplyUrlmimics the public.../mascsr/default/mdf/recruitment/recruitment.html?cid=...&ccId=...shape purely as a string — it never resolves to a real host. - No account is ever created. The fixture's "Create account" path
(
/mascsr/default/mdf/recruitment/create-account) is a local-only simulated redirect; it sets anAccountCreationSimulatedflag for tests to observe and nothing else. The engine'sSignInAsyncalways takes the "Sign In" path against the local fixture with a throwaway password, never "Create account," and never touches any real identity system (no real myADP account, ever). - Submit is a no-op. The Review step's submit control is located and verified to exist, then the
adapter logs
would submit (SUPPRESSED)and never clicks it. The fixture's/mascsr/default/mdf/recruitment/submithandler only sets a flag and returns a "LOCAL NO-OP" page. - Hard assertion. Each iteration asserts
FakeAdpSite.SubmitWasRequested == falseafter the run; a true value throws aSAFETY VIOLATIONand is counted as a failure. Across all validation runs this never fired. - Realistic data, local only. The fill-input is the Andrew Jones seed (reusing Core's
IntakeProfileshapes: Full Sail University B.S. Game Development, Equifax). It is never transmitted anywhere.
Detection¶
AtsKind.Adp and a workforcenow.adp.com / myjobs.adp.com detection branch were added to the
production AtsDetector (additive only — no existing detection logic was changed). Because the
fixture itself must stay loopback-only, each loop iteration synthesizes a realistic
https://workforcenow.adp.com/mascsr/default/mdf/recruitment/recruitment.html?cid=<synthetic>&ccId=<synthetic>
string (never resolved, never dialed) and confirms the detector classifies it as AtsKind.Adp
before driving the flow — the same "confirm detection matches the real router" discipline the
UKG/Paycom labs use.
Running it¶
Short validation:
dotnet run --project tools/WorkWingman.ScraperLab.Adp -- --iterations 20
Long local hardening run (time-bounded, e.g. an hour), writing a rolling JSONL log:
dotnet run --project tools/WorkWingman.ScraperLab.Adp -- --minutes 60 --out adp-run.jsonl
Each JSONL line is one iteration: variation, detected ATS, outcome, steps completed, ambiguous count, missed fields, submit-suppressed flag, elapsed ms, and the per-iteration log. The console prints a rolling summary: selector hit/miss table (most-missed first), fields that missed the whole chain, and failure-mode counts.
Playwright's Chromium is required once:
pwsh tools/WorkWingman.ScraperLab.Adp/bin/Debug/net10.0/playwright.ps1 install chromium
Tests:
dotnet test tools/WorkWingman.ScraperLab.Adp.Tests
Takeaways for a production ADP adapter¶
- Lead with
formcontrolname, fall back toid, thenname, then label, thenaria-label. This priority order is the opposite of Paycom's (which leads withid) — carrying Paycom'sid-first lesson into ADP unmodified would have been the wrong call. Always confirm the actual most-durable anchor for a new ATS family rather than assuming the last lab's ordering transfers. - Treat "which branch of the flow am I on?" as a selector-chain problem, not a single-selector check. The Voluntary-Disclosures-location probe generalizes cleanly from the Paycom lab's EEO-location bug: any branch decision needs the same multi-tier fallback discipline as filling a field, or it will silently mis-route on exactly the fuzzed variations the lab exists to catch.
- Additional/custom questions have no fixed schema. Probe generically for whichever questions a requisition renders; never assume a specific set exists.
- Never create a real candidate account during automated hardening. Only ever sign in against a local fixture with throwaway credentials — there is no such thing as a "test" myADP account that's safe to create against the real site.
- Flag ambiguity, don't guess — carry the Workday/UKG/Paycom judgement-call discipline into ADP (degree format, unmapped custom questions).
- Keep submit suppressed and asserted — the safety boundary is a hard invariant, not a convention.