# ATS Form Automation Details
## SmartRecruiters Form Automation
- SmartRecruiters uses 36+ shadow roots with SPL-* web components (Lit elements)
- Standard querySelector CANNOT find elements inside shadow DOM — use recursive deepQuery:
```js
function dqAll(r,sel){var results=[];r.querySelectorAll(sel).forEach(function(e){results.push(e);});
r.querySelectorAll('*').forEach(function(e){if(e.shadowRoot){dqAll(e.shadowRoot,sel).forEach(function(f){results.push(f);});}});return results;}
```
- **Personal info inputs**: `#first-name-input`, `#last-name-input`, `#email-input`, `#confirm-email-input`, phone via `spl-form-element`, `#linkedin-input`
- **Setting input values**: Must use React native setter + dispatch events:
```js
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el,val);
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
```
- **Textarea** (cover letter): Same pattern but with `HTMLTextAreaElement.prototype`
- **File upload**: Browser blocks JS `.click()` on file inputs. Workaround: local HTTP server + fetch + DataTransfer:
```js
var resp=await fetch('http://localhost:PORT/file.pdf');
var file=new File([await resp.blob()],'file.pdf',{type:'application/pdf'});
var dt=new DataTransfer(); dt.items.add(file);
fileInput.files=dt.files; fileInput.dispatchEvent(new Event('change',{bubbles:true}));
```
- **SPL-AUTOCOMPLETE dropdowns** (CRITICAL — most complex part):
- JS `.click()` on `spl-select-option` does NOT update Lit component internal state
- Physical clicks via cliclick are unreliable (coordinate drift between fields)
- **WORKING APPROACH**: JS focus+clear via React setter, then System Events keystroke for typing, then keyCode Down+Enter:
1. `inp.focus()` + native setter to clear value + dispatch input/change events
2. `se.keystroke('unique filter text')` — type UNIQUE prefix to filter to exactly 1 option
3. Wait 1.5s for dropdown to filter
4. `se.keyCode(125)` (down) + `se.keyCode(36)` (enter) to select
- MUST type enough text to uniquely match ONE option (e.g., "f. No, I am not a current" not just "No")
- **SPL-RADIO-GROUP**: Find SPL-RADIO children, click the one with matching label
- **SPL-BUTTON**: Find via `dqAll(document,'spl-button')`, get inner `button` via shadowRoot for reliable click
- **Navigation**: "Next" button is typically `spl-button[type=primary]` index 2; "Submit" is index 1 on questions page
- **Bot detection**: Chromium/Playwright gets blocked ("access temporarily restricted"). Must use real Chrome + macOS Automator (AppleScript/JXA)
- **Playwright MCP CDP fails** on SmartRecruiters due to strict CSP blocking chrome-extension:// URLs
- **cliclick pitfalls**: NEVER use `kd:cmd t:a ku:cmd` to select all — can trigger fullscreen or other shortcuts. Use triple-click (`tc:`) or JS clearing instead
- **Chrome window offset**: bounds from AppleScript give left,top,right,bottom. Content area = left+viewportX, top+toolbar(~80px)+viewportY
## General Automation Strategy (Platform Decision Tree)
- **Standard HTML forms** (job-room.ch, most ATS): Try Playwright MCP first → macOS Automator JXA fallback
- **Ashby HQ** (jobs.ashbyhq.com): Playwright MCP works well — standard React app, no shadow DOM. Yes/No buttons need scrollIntoView before click
- **Shadow DOM / Lit / Web Components** (SmartRecruiters): macOS Automator JXA only — Playwright MCP can't pierce shadow DOM reliably and gets blocked by CSP
- **Workday**: macOS Automator JXA (Playwright blocked by extension CSP)
- **Personio**: Playwright MCP works but datepicker needs browser_click + browser_type(slowly=true)
- **Sword Services / Teamtailor** (sword-services.ch): Playwright MCP works with 1PW-safe pattern. MUST disable 1PW first (see global CLAUDE.md). Use browser_run_code with native setters for name/email, pressSequentially for phone, label clicks for radios, evaluate(el=>el.click()) for checkboxes, especially privacy checkbox. hCaptcha requires manual solve. Separate CV and cover letter upload fields. Form URL pattern: /l/en/o/{slug}/c/new
- **Chromium has no persistent sessions** — when using `--browser chromium` (no user-data-dir), sites requiring login (job-room.ch, etc.) need manual login each time. Navigate to the login page, tell user to log in, wait for confirmation before proceeding. (1Password API integration TBD)
- **macOS Automator JXA pattern**: `Application('Google Chrome').windows[0].activeTab.execute({javascript: code})` for DOM, `Application('System Events').processes['Google Chrome']` for keyboard
- **System Events key codes**: 125=down, 126=up, 36=return/enter, 53=escape, 48=tab, 49=space, 51=delete
- **cliclick** (`/opt/homebrew/bin/cliclick`): Use for physical mouse clicks when needed. Key names: `esc`, `arrow-down`, `return`, `delete`, `tab`, `space`. NEVER use `kd:cmd` sequences (trigger OS shortcuts). Prefer JS focus + System Events over cliclick for typing into fields.
- **Local HTTP server for file uploads**: `python3 -m http.server PORT` in artifacts dir, then fetch+File+DataTransfer in JS to bypass file input security
## Ashby HQ Form Automation (jobs.ashbyhq.com)
- Used by: DualEntry (and likely other YC/VC-backed startups)
- Standard HTML form — no shadow DOM, no web components, straightforward React app
- **Yes/No toggle buttons**: CSS module classes, NOT aria-pressed. Selection state = `_active_y2cw4_58` class + dark bg `rgb(15,55,62)`
- Playwright MCP `.click()` on Yes buttons does NOT stick if button is not scrolled into view
- **WORKING APPROACH**: Use `browser_run_code` with `scrollIntoViewIfNeeded()` + `click({force:true})` + delay per button:
```js
for (let i = 0; i < yesButtons.length; i++) {
await yesButtons[i].scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
await yesButtons[i].click({ force: true });
await page.waitForTimeout(500);
}
```
- The accessibility snapshot does NOT reflect [active] state on Yes buttons — verify via JS `classList.contains('_active_y2cw4_58')`
- **File upload**: Works via standard Playwright `browser_file_upload` after clicking "Upload File" button (triggers file chooser)
- **Location combobox**: Type slowly, wait for dropdown, click matching option — standard combobox behavior
- **Text fields**: Standard `.fill()` works fine for all textbox fields
## Sword Services Form Automation (sword-services.ch)
- **Platform**: Teamtailor ATS — standard HTML forms, Playwright MCP works
- **URL pattern**: `https://sword-services.ch/l/en/o/{job-slug}/c/new`
- **Recurring provider**: Sword Group posts frequently — automate fully
- **NEVER use `browser_fill_form` with multiple fields** — always fails on second field (ref e89 bug). Use individual `browser_type`/`browser_click` calls
- **Phone number widget** (TRICKY):
- Country code selector is a custom listbox, NOT a native select
- `fill()` on the phone field resets the country code on blur
- **WORKING APPROACH**: Use `browser_run_code` with `pressSequentially('+41764502698', {delay: 80})` after triple-click to clear. This auto-formats to "+41 76 450 26 98" and correctly sets country to "Suisse"
- Do NOT try to set country separately then type digits — it reverts on other interactions
- **Radio buttons**: Labels intercept pointer events on the ``. Click the label generic ref (e.g., e157 for "Suisse"), NOT the radio ref (e.g., e156)
- **Checkboxes**: Click via `getByText('label text').click()` or the label generic ref
- **Privacy checkbox** (CRITICAL): The label contains a `` link to the privacy policy. Clicking the label navigates away from the form! **MUST use `browser_run_code` with `el.evaluate(el => el.click())` on the checkbox input directly**, or use `getByText()` on the non-link part
- **hCaptcha**: Appears after clicking "Envoyer" (submit). Image-based CAPTCHA — user must solve manually. Sometimes has "Passer" (Skip) button
- **File uploads**: Standard — click upload button (triggers file chooser), then `browser_file_upload` with file path. Separate CV and CL upload fields
- **Form fields (Matthias standard values)**:
- Nom complet: Matthias Nott
- Email: mnott@mnott.ch
- Phone: +41764502698 (typed via pressSequentially)
- Nationalité: Allemande
- Résidence: Suisse (radio)
- Permis: Citoyen.ne Suisse (radio)
- Disponibilité: Immédiate
- Lieu de résidence: Déja installé en Suisse (checkbox)
- Mobilité: Genève + Lausanne (checkboxes, adjust per job location)
- Privacy: Accept (checkbox, click via JS)
- **Navigation survives back**: Form state partially persists on browser back (text fields, radios survive; phone digits may reset; checkbox state may reset)
- **Recommended fill order**: (1) text fields, (2) phone via run_code, (3) radios via label click, (4) checkboxes + privacy via run_code batch, (5) file uploads, (6) pause for user before Envoyer
- **PDF filenames**: Use descriptive names like "CV_Matthias_Nott_Chef_Projet_IT.pdf" not "CV_Geneva.pdf"
- **Submit**: Single "Submit Application" button, success message: "Your application to build the future of ERP was successfully submitted"
- **LinkedIn "Apply on company website" flow**: Button opens external URL in new tab that Playwright MCP cannot see. Use AppleScript to list all Chrome tabs and find the Ashby URL
## Playwright MCP Chrome-Extension Crash Pattern
- **Root cause discovered**: Multiple Playwright MCP extension tabs accumulate in Chrome over time (visible via AppleScript tab listing)
- The error `Protocol error (Target.setAutoAttach): Cannot access a chrome-extension:// URL of different extension` occurs when Playwright MCP tries to attach to a tab showing another extension's URL
- **Site-specific**: Crashes happen more on sites that trigger extension interactions (job-room.ch with eIAM SAML auth, SmartRecruiters with CSP). Simple sites like Ashby work fine with Playwright
- **Recovery sequence**: `pkill -f "@playwright/mcp"` → if still failing, `browser_close` → if still failing, quit Chrome entirely (`osascript -e 'tell application "Google Chrome" to quit'`) and let Playwright relaunch. Note: quitting Chrome loses session cookies and may require re-login
- **Fallback**: When Playwright is unstable on a site, use JXA immediately instead of retrying
## Sword Services (Job Alerts)
- Alert emails come from `noreply@tellentalerts.com` with subject "New opportunity at Sword Services for you"
- Email body contains job title + location but NO clickable link
- Careers page: `https://sword-services.ch/l/en/offres` (lists all open roles)
- Individual JD URL pattern: `https://sword-services.ch/l/en/o/{slug}` — slug is title lowercased, spaces→hyphens (e.g., `mdm-technical-lead-architect-reltio`)
- Recruitee-based ATS (redirects from swordservices.recruitee.com → sword-services.ch)
- Offices: Geneva, Nyon, Lausanne, Fribourg, Sion
## Workday Form Automation
- Chrome "Allow JavaScript from Apple Events" must be enabled manually (View > Developer menu) — automation toggle doesn't stick due to system confirmation dialog
- Playwright MCP Bridge has persistent "Cannot access chrome-extension:// URL" issue — use AppleScript JS execution instead
- Workday multiselect dropdowns resist synthetic JS clicks — physical keyboard/mouse via System Events may be needed for dropdowns
- Text input fields work fine with native value setter + input/change events using element IDs (not data-automation-id)
- Input field IDs follow pattern: `sectionName--fieldName` (e.g., `name--legalName--lastName`, `address--postalCode`)