atsfill 0.0.3.dev6__tar.gz → 0.0.3.dev8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/PKG-INFO +2 -2
  2. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/README.md +1 -1
  3. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/ashby.py +43 -16
  6. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/greenhouse.py +37 -46
  7. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/registry.toml +3 -2
  8. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/LICENSE +0 -0
  9. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/__init__.py +0 -0
  11. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/teamtailor.py +0 -0
  12. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/wttj.py +0 -0
  13. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/cli.py +0 -0
  14. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/__init__.py +0 -0
  15. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/accounts.py +0 -0
  16. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/adapter.py +0 -0
  17. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/attempts.py +0 -0
  18. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/browser.py +0 -0
  19. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/commands.py +0 -0
  20. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/matching.py +0 -0
  21. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/paths.py +0 -0
  22. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/registry.py +0 -0
  23. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/results.py +0 -0
  24. {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.3.dev6
3
+ Version: 0.0.3.dev8
4
4
  Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
5
5
  Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
6
6
  Author: shbernal
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
53
53
  ## Supported hiring systems
54
54
 
55
55
  <!-- badges -->
56
- ![ashby: send broken 2026-10-19](https://img.shields.io/badge/ashby-send%20broken%202026--10--19-f7768e) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
56
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
57
57
  <!-- /badges -->
58
58
 
59
59
  | System | Account needed | `read` without a browser |
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
29
29
  ## Supported hiring systems
30
30
 
31
31
  <!-- badges -->
32
- ![ashby: send broken 2026-10-19](https://img.shields.io/badge/ashby-send%20broken%202026--10--19-f7768e) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
32
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev6"
3
+ version = "0.0.3.dev8"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev6"
3
+ version = "0.0.3.dev8"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,12 +1,14 @@
1
1
  """Ashby (`jobs.ashbyhq.com`, and career pages embedding it), as docs/adapters/ashby.md observes.
2
2
 
3
- Every change posts `ApiSetFormValue`, whose response holds the form as Ashby will receive it:
4
- that is the read-back, kept per form since a survey answer returns the survey alone.
3
+ Every change posts a mutation whose response holds the form as Ashby will receive it: that is
4
+ the read-back, kept per form since a survey answer returns the survey alone.
5
5
  """
6
6
 
7
7
  import json
8
8
  import time
9
9
  import urllib.request
10
+ from collections.abc import Callable
11
+ from functools import partial
10
12
  from typing import Any
11
13
  from urllib.parse import parse_qs, urlsplit
12
14
 
@@ -31,6 +33,7 @@ TYPES = {
31
33
  "String": FieldType.TEXT,
32
34
  "Email": FieldType.TEXT,
33
35
  "LongText": FieldType.TEXT,
36
+ "Url": FieldType.TEXT,
34
37
  "Date": FieldType.DATE,
35
38
  "Number": FieldType.TEXT,
36
39
  "Phone": FieldType.PHONE,
@@ -42,6 +45,7 @@ TYPES = {
42
45
  }
43
46
  SETTLE_S = 1.5
44
47
  READ_BACK_TIMEOUT_S = 30
48
+ SAVE_TIMEOUT_MS = 30_000
45
49
  SUBMIT_TIMEOUT_MS = 90_000
46
50
 
47
51
 
@@ -70,7 +74,7 @@ class Ashby:
70
74
 
71
75
  def __init__(self) -> None:
72
76
  self._forms: list[dict[str, Any]] = [] # the application form, then each survey
73
- self._pending = 0
77
+ self._pending: set[Any] = set() # GraphQL requests in flight, since `read_form`
74
78
  self._last_change = 0.0
75
79
  self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
76
80
 
@@ -132,6 +136,7 @@ class Ashby:
132
136
  return [self._field(e) for e in self._entries()]
133
137
 
134
138
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
139
+ """One save at a time: saves in flight together overwrite each other at Ashby."""
135
140
  fields = {e["field"]["path"]: self._field(e) for e in self._entries()}
136
141
  for field_id, answer in answers.items():
137
142
  entry = page.locator(f'[data-field-path="{field_id}"]')
@@ -140,30 +145,49 @@ class Ashby:
140
145
  case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
141
146
  box = entry.locator("input, textarea").first
142
147
  box.fill(answer)
143
- box.blur()
148
+ self._saved(page, box.blur)
149
+ case FieldType.CHOICE, str() if entry.locator("input[role=combobox]").count():
150
+ entry.locator("input[role=combobox]").fill(answer)
151
+ option = page.get_by_role("option", name=answer, exact=True)
152
+ self._saved(page, option.click)
144
153
  case FieldType.CHOICE, str():
145
- entry.get_by_text(answer, exact=True).click()
154
+ self._saved(page, entry.get_by_text(answer, exact=True).click)
146
155
  case FieldType.MULTI_CHOICE, list():
147
156
  for value in answer:
148
- entry.get_by_text(value, exact=True).click()
157
+ self._saved(page, entry.get_by_text(value, exact=True).click)
149
158
  case FieldType.TYPEAHEAD, str():
150
- box = entry.locator("input[role=combobox]")
151
- box.fill(answer)
152
- page.get_by_role("option").filter(has_text=answer).first.click()
159
+ entry.locator("input[role=combobox]").fill(answer)
160
+ option = page.get_by_role("option").filter(has_text=answer).first
161
+ self._saved(page, option.click)
153
162
  case FieldType.FILE, str():
154
- entry.locator("input[type=file]").set_input_files(answer)
163
+ upload = entry.locator("input[type=file]")
164
+ self._saved(page, partial(upload.set_input_files, answer))
155
165
  case kind, _:
156
166
  raise Unsupported(
157
167
  f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
158
168
  )
159
169
  self._answered = list(answers)
160
170
 
171
+ def _saved(self, page: Page, change: Callable[[], None]) -> None:
172
+ """Makes the change, then waits for its save and any other request it set off. A
173
+ change Ashby does not save shows in the read-back."""
174
+ try:
175
+ with page.expect_response(
176
+ lambda r: _op(r).startswith("ApiSetFormValue"), timeout=SAVE_TIMEOUT_MS
177
+ ):
178
+ change()
179
+ except Timeout:
180
+ return
181
+ deadline = time.monotonic() + SAVE_TIMEOUT_MS / 1000
182
+ while self._pending and time.monotonic() < deadline:
183
+ page.wait_for_timeout(100)
184
+
161
185
  def read_back(self, page: Page) -> dict[str, Answer]:
162
186
  """Ashby's last returned form, once every answer is held and no request is in flight."""
163
187
  deadline = time.monotonic() + READ_BACK_TIMEOUT_S
164
188
  while time.monotonic() < deadline:
165
189
  values = self._values()
166
- quiet = self._pending == 0 and time.monotonic() - self._last_change > SETTLE_S
190
+ quiet = not self._pending and time.monotonic() - self._last_change > SETTLE_S
167
191
  if quiet and all(values.get(i) is not None for i in self._answered):
168
192
  break
169
193
  page.wait_for_timeout(250)
@@ -248,15 +272,18 @@ class Ashby:
248
272
 
249
273
  def _on_request(self, request: Any) -> None:
250
274
  if "/api/non-user-graphql" in request.url:
251
- self._pending += 1
275
+ self._pending.add(request)
252
276
 
253
277
  def _on_finished(self, request: Any) -> None:
254
278
  if "/api/non-user-graphql" in request.url:
255
- self._pending -= 1
279
+ self._pending.discard(request)
256
280
  self._last_change = time.monotonic()
257
281
 
258
282
  def _on_response(self, response: Response) -> None:
259
- if _op(response) == "ApiSetFormValue" and response.ok:
260
- render = response.json()["data"]["setFormValue"]
261
- if render:
283
+ """Any response holding a form render: a value goes to `ApiSetFormValue`, a file to
284
+ `ApiSetFormValueToFile` once its upload ends, after saves made later."""
285
+ if not _op(response).startswith("ApiSetFormValue") or not response.ok:
286
+ return
287
+ for render in (response.json().get("data") or {}).values():
288
+ if isinstance(render, dict) and "sections" in render:
262
289
  self._replace(render)
@@ -2,13 +2,14 @@
2
2
  pages embedding them), as docs/adapters/greenhouse.md observes.
3
3
 
4
4
  Nothing but an attached file reaches Greenhouse before submit, so the read-back is the page:
5
- input values, the shown value of each select, the phone country's flag, the attached file's name.
5
+ input values, the shown value of each select, the phone's dial code, the attached file's name.
6
6
  """
7
7
 
8
8
  import json
9
9
  import re
10
10
  import time
11
11
  import urllib.request
12
+ from contextlib import suppress
12
13
  from typing import Any
13
14
  from urllib.parse import parse_qs, urlencode, urlsplit
14
15
 
@@ -25,6 +26,7 @@ EMBED_TIMEOUT_MS = 15_000
25
26
  OPTIONS_TIMEOUT_MS = 3_000
26
27
  HYDRATE_TIMEOUT_MS = 15_000
27
28
  FORM_TIMEOUT_MS = 20_000
29
+ ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
28
30
  SUBMIT_TIMEOUT_MS = 90_000
29
31
  API_TYPES = {
30
32
  "input_text": FieldType.TEXT,
@@ -34,6 +36,9 @@ API_TYPES = {
34
36
  "multi_value_multi_select": FieldType.MULTI_CHOICE,
35
37
  }
36
38
  YES_NO = ["Yes", "No"]
39
+ # Sections the page adds or names its own way, or shows a question of only on another answer
40
+ # (EEOC race after Hispanic/Latino): `read_api` declines a posting with any (ADR 0011).
41
+ UNMAPPED = ("education", "compliance", "demographic_questions")
37
42
 
38
43
  # The fields in page order. A select's options are read by opening it, from Python.
39
44
  _READ = """form => {
@@ -48,12 +53,14 @@ _READ = """form => {
48
53
  const required = el => el.getAttribute("aria-required") === "true" || el.required;
49
54
  const found = [];
50
55
  for (const el of form.querySelectorAll("input, textarea")) {
51
- if (!el.id || ["hidden", "search", "file", "checkbox", "radio"].includes(el.type)) continue;
56
+ if (!el.id || ["hidden", "search", "file", "checkbox"].includes(el.type)) continue;
52
57
  const combo = el.getAttribute("role") === "combobox";
58
+ if (combo && el.closest(".phone-input")) continue; // the phone's country, set by its number
53
59
  const shell = el.closest(".select");
54
60
  const multi = combo && !!shell?.querySelector(".select__value-container--is-multi");
55
61
  found.push([el, {id: el.id, label: text(labelFor(el)), required: required(el),
56
- kind: combo ? (multi ? "multi" : "select") : el.type === "tel" ? "tel" : "text",
62
+ kind: combo ? (multi ? "multi" : "select")
63
+ : ["tel", "number", "radio"].includes(el.type) ? el.type : "text",
57
64
  maxLength: el.maxLength > 0 ? el.maxLength : null}]);
58
65
  }
59
66
  for (const group of form.querySelectorAll("fieldset.checkbox")) {
@@ -80,8 +87,8 @@ _READ = """form => {
80
87
  return found.map(([, field]) => field);
81
88
  }"""
82
89
 
83
- # What each field shows: a select its shown value (the phone country its flag's code), a file
84
- # its attached name, a checkbox group its checked labels.
90
+ # What each field shows: a select its shown value, a file its attached name, a checkbox group
91
+ # its checked labels, the phone its country's dial code then its number.
85
92
  _READ_BACK = """(form, ids) => {
86
93
  const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : null;
87
94
  const out = {};
@@ -94,10 +101,12 @@ _READ_BACK = """(form, ids) => {
94
101
  .map(b => text(document.querySelector(`label[for="${CSS.escape(b.id)}"]`)));
95
102
  } else if (el?.getAttribute("role") === "combobox") {
96
103
  const select = el.closest(".select");
97
- const flag = select.querySelector(".select__single-value .iti__flag");
98
104
  const multi = [...select.querySelectorAll(".select__multi-value__label")].map(text);
99
- out[id] = flag ? "flag:" + [...flag.classList].find(c => /^iti__[a-z]{2}$/.test(c)).slice(5)
100
- : multi.length ? multi : text(select.querySelector(".select__single-value"));
105
+ out[id] = multi.length ? multi : text(select.querySelector(".select__single-value"));
106
+ } else if (el?.type === "tel" && el.closest(".phone-input")) {
107
+ const dial = text(el.closest(".phone-input").querySelector(".select__single-value"));
108
+ const number = el.value.startsWith(dial) ? el.value.slice(dial.length).trim() : el.value;
109
+ out[id] = dial && number ? `${dial} ${number}` : null;
101
110
  } else {
102
111
  const box = form.querySelector(`input[type=checkbox][name="${id}"]`) ?? el;
103
112
  out[id] = box?.type === "checkbox" ? (box.checked ? "Yes" : "No") : el?.value ?? null;
@@ -117,11 +126,11 @@ class Greenhouse:
117
126
  def __init__(self) -> None:
118
127
  self._fields: dict[str, Field] = {}
119
128
  self._kinds: dict[str, str] = {} # how the page renders each field
120
- self._flags: dict[str, dict[str, str]] = {} # per select, option text by flag code
121
129
  self._embed: str | None = None # the job_app URL a company page embeds
122
130
 
123
131
  def detect(self, url: str, page: Page | None) -> bool:
124
132
  """A board URL, or a company page whose `#grnhse_iframe` embeds a job application."""
133
+ self._embed = None # one process detects many postings: a survey, the gate
125
134
  if _board_job(url):
126
135
  return True
127
136
  if page is None:
@@ -181,7 +190,8 @@ class Greenhouse:
181
190
  case "file":
182
191
  upload = page.locator(_upload(field_id))
183
192
  upload.locator("input[type=file]").set_input_files(values[0])
184
- upload.locator(".file-upload__filename").wait_for()
193
+ with suppress(Timeout):
194
+ upload.locator(".file-upload__filename").wait_for(timeout=ANSWER_TIMEOUT_MS)
185
195
  case "consent":
186
196
  box = page.locator(f"{FORM} input[type=checkbox][name='{field_id}']").first
187
197
  if box.is_checked() != (answer == "Yes"):
@@ -194,15 +204,15 @@ class Greenhouse:
194
204
  suggested = self._fields[field_id].type is FieldType.TYPEAHEAD
195
205
  for value in values:
196
206
  _select(page, field_id, value, suggested)
207
+ case "tel": # the number's dial code picks the country
208
+ page.locator(_by_id(field_id)).press_sequentially(values[0], delay=20)
209
+ case "number" if not re.fullmatch(r"-?\d+(\.\d+)?", values[0]):
210
+ pass # the input takes no text: left empty, it reads back as a mismatch
197
211
  case _:
198
212
  page.locator(_by_id(field_id)).fill(values[0])
199
213
 
200
214
  def read_back(self, page: Page) -> dict[str, Answer]:
201
215
  values = page.locator(FORM).evaluate(_READ_BACK, list(self._fields))
202
- for field_id, flags in self._flags.items():
203
- shown = values.get(field_id)
204
- if isinstance(shown, str) and shown.startswith("flag:"):
205
- values[field_id] = flags.get(shown[5:], shown)
206
216
  return {k: v for k, v in values.items() if v not in (None, "", [])}
207
217
 
208
218
  def submit(self, page: Page) -> str | None:
@@ -213,7 +223,7 @@ class Greenhouse:
213
223
  def _field(self, page: Page, raw: dict[str, Any]) -> Field:
214
224
  kind, options = raw["kind"], raw.get("options")
215
225
  match kind:
216
- case "text":
226
+ case "text" | "number":
217
227
  field_type = FieldType.TEXT
218
228
  case "tel":
219
229
  field_type = FieldType.PHONE
@@ -229,7 +239,7 @@ class Greenhouse:
229
239
  field_type = FieldType.TYPEAHEAD
230
240
  else:
231
241
  field_type = FieldType.MULTI_CHOICE if kind == "multi" else FieldType.CHOICE
232
- case _:
242
+ case _: # a radio input, not seen on a posting yet
233
243
  raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
234
244
  return Field(
235
245
  raw["id"],
@@ -252,12 +262,6 @@ class Greenhouse:
252
262
  box.press("Escape")
253
263
  return None
254
264
  options = [t.strip() for t in menu.all_inner_texts()]
255
- flags = menu.evaluate_all(
256
- "items => items.map(i => [...(i.querySelector('.iti__flag')?.classList ?? [])]"
257
- ".find(c => /^iti__[a-z]{2}$/.test(c))?.slice(5))"
258
- )
259
- if any(flags):
260
- self._flags[field_id] = {f: o for f, o in zip(flags, options, strict=True) if f}
261
265
  box.press("Escape")
262
266
  return options
263
267
 
@@ -294,39 +298,22 @@ def _board_job(url: str) -> tuple[str, str] | None:
294
298
 
295
299
 
296
300
  def _api_fields(job: dict[str, Any]) -> list[Field] | None:
297
- if job.get("education") or any(
301
+ if any(job.get(k) for k in UNMAPPED) or any(
298
302
  c.get("requires_consent") or c.get("requires_processing_consent")
299
303
  for c in job.get("data_compliance") or []
300
304
  ):
301
- return None # the page adds education rows or consent boxes the API does not list
305
+ return None
302
306
  fields = []
303
307
  for question in job["questions"] + (job.get("location_questions") or []):
304
308
  entry = question["fields"][0] # a file's paste-in textarea comes second
305
- if entry["type"] == "input_hidden":
309
+ if entry["type"] == "input_hidden": # a location's latitude and longitude
306
310
  continue
307
311
  name = entry["name"]
308
- field_type = API_TYPES[entry["type"]]
309
- options = [v["label"] for v in entry.get("values") or []] or None
310
- if name == "phone":
311
- fields.append(Field("country", "Country", FieldType.CHOICE, question["required"]))
312
- field_type = FieldType.PHONE
312
+ field_type = FieldType.PHONE if name == "phone" else API_TYPES[entry["type"]]
313
313
  if name == "location":
314
314
  name, field_type = "candidate-location", FieldType.TYPEAHEAD
315
+ options = [v["label"] for v in entry.get("values") or []] or None
315
316
  fields.append(Field(name, question["label"], field_type, question["required"], options))
316
- for section in job.get("compliance") or []:
317
- for question in section["questions"]:
318
- entry = question["fields"][0]
319
- options = [v["label"] for v in entry.get("values") or []] or None
320
- kind = API_TYPES[entry["type"]]
321
- fields.append(
322
- Field(entry["name"], question["label"], kind, question["required"], options)
323
- )
324
- for question in (job.get("demographic_questions") or {}).get("questions", []):
325
- options = [o["label"] for o in question["answer_options"]]
326
- kind = API_TYPES[question["type"]]
327
- fields.append(
328
- Field(str(question["id"]), question["label"], kind, question["required"], options)
329
- )
330
317
  return fields
331
318
 
332
319
 
@@ -345,12 +332,16 @@ def _exact(value: str) -> re.Pattern[str]:
345
332
 
346
333
  def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
347
334
  """Types into a react-select and picks the option: hovered then Enter, which every select
348
- takes (Country saves only on Enter). A suggestion list takes its first suggestion."""
335
+ takes. A suggestion list takes its first suggestion. No option leaves the select empty."""
349
336
  box = page.locator(_by_id(field_id))
350
337
  box.click()
351
338
  box.press_sequentially(value, delay=20)
352
339
  menu = page.locator(f"{_by_id(f'react-select-{field_id}-listbox')} [role=option]")
353
340
  option = menu.first if suggested else menu.filter(has_text=_exact(value)).first
354
- option.wait_for()
341
+ try:
342
+ option.wait_for(timeout=ANSWER_TIMEOUT_MS)
343
+ except Timeout:
344
+ box.press("Escape")
345
+ return
355
346
  option.hover()
356
347
  box.press("Enter")
@@ -19,8 +19,8 @@ kind = "ats"
19
19
  account = "none"
20
20
  hosting = ["hosted", "embedded"]
21
21
  adapter = "ashby"
22
- send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal", commit = "ff8ea46" }
23
- send_broken = { date = 2026-10-19, version = "0.0.2", commit = "916319c", by = "shbernal", reason = "the résumé reads back empty after its upload", cause = "unknown" }
22
+ send_verified = { date = 2026-10-19, version = "0.0.2", by = "shbernal", commit = "7622376" }
23
+ dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "c230c5e" }
24
24
 
25
25
  [teamtailor]
26
26
  kind = "ats"
@@ -33,6 +33,7 @@ kind = "ats"
33
33
  account = "none"
34
34
  hosting = ["hosted", "embedded"]
35
35
  adapter = "greenhouse"
36
+ dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "c0fb0c5" }
36
37
 
37
38
  [wttj]
38
39
  kind = "board"
File without changes