@diegosouzacdv/jev-browser-mcp 0.4.1 → 0.4.2

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.
package/README.md CHANGED
@@ -100,7 +100,10 @@ Cada plano pode usar:
100
100
  o MCP ignora diálogos visualmente ocultos, inclusive os que ficaram no DOM
101
101
  com `opacity: 0`;
102
102
  - `name: ""` com `index` não negativo para controles sem nome. O resultado
103
- inclui um aviso porque a posição pode mudar entre execuções;
103
+ inclui um aviso porque a posição pode mudar entre execuções. O índice é
104
+ zero-based para o mesmo papel e segue o localizador Playwright
105
+ `{role, name: ""}`; controles desabilitados não aparecem no diagnóstico, mas
106
+ continuam contando para que o índice aponte ao controle correto;
104
107
  - `selector` com CSS ou XPath como último recurso. O MCP identifica essa escolha
105
108
  em `warnings`; não é permitido enviar JavaScript nem coordenadas;
106
109
  - `timeout_seconds` opcional em cada etapa, limitado pela configuração central;
@@ -108,8 +111,10 @@ Cada plano pode usar:
108
111
  `blur: true` para desfocar o campo e `sensitive: false` para permitir que o
109
112
  valor apareça nas evidências. Nesse modo, `\n` envia Enter e `\t` envia Tab;
110
113
  - `wait_for_text` e `wait_for_condition` (`network_idle`, `hidden` ou
111
- `text_hidden`); `network_idle` aceita `url_contains` para aguardar só as
112
- requisições correspondentes;
114
+ `text_hidden`). `wait_for_text` aceita `fail_on: {"role":"alert"}` para
115
+ interromper a espera assim que um alerta visível aparecer e incluir seu texto
116
+ no erro. `network_idle` aceita `url_contains` para aguardar só as requisições
117
+ correspondentes;
113
118
  - `press` (o alias legado `press_key` também é aceito) com `PageDown`, `PageUp`,
114
119
  `Home`, `End`, setas, `Enter`, `Escape`, `Tab` ou `Shift+Tab`. Sem alvo,
115
120
  envia a tecla ao elemento focado; com alvo, usa o localizador informado;
@@ -158,9 +163,12 @@ Exemplos para controles legados sem nome acessível:
158
163
  {"action":"click","selector":"#save-document"}
159
164
  ```
160
165
 
161
- O reconhecimento retorna `unnamed_controls` com papel, posição, rótulo mais
162
- próximo e um trecho HTML sanitizado dos controles interativos sem nome. O
163
- snapshot também resume campos de formulário com `id`, `name`, valor, estado
166
+ O reconhecimento retorna `unnamed_controls_initial` e
167
+ `unnamed_controls_final`, cada um com papel, índice Playwright por papel, rótulo
168
+ mais próximo e um trecho HTML sanitizado dos controles visíveis e habilitados
169
+ sem nome. `unnamed_controls` continua disponível como alias da lista final. O
170
+ placeholder conta como nome acessível. O snapshot também resume campos de
171
+ formulário com `id`, `name`, valor, estado
164
172
  desabilitado, rótulo e índice zero-based entre campos de mesmo nome. Valores
165
173
  sensíveis, como senha, token e cartão, são ocultados. `snapshot_include_hidden`
166
174
  é `false` por padrão; defina `true` somente quando precisar inspecionar campos
@@ -321,8 +329,16 @@ esse resultado e `expected_outcome_visible` continua descrevendo somente o
321
329
  texto global. `incomplete` significa que nenhum critério foi comprovado;
322
330
  confiança do Jev não substitui essa verificação. `timings_ms.ready_ms` mede a
323
331
  espera da SPA; `warnings` registra capturas vazias durante transições; e
324
- `failed_step` identifica índice, ação, alvo, timeout e erro resumido quando uma
325
- etapa falha.
332
+ `failed_step` identifica índice, ação, localizador, timeout e erro resumido
333
+ quando uma etapa falha. Cada etapa executada também registra a composição do
334
+ localizador usado e, quando disponível, `resolved_target` com tag, `id`, papel
335
+ e nome acessível do elemento resolvido. Etapas `type` só incluem o valor final
336
+ do campo quando `sensitive: false`.
337
+
338
+ Com `capture_network_error_bodies: true`, respostas JSON de erro da mesma
339
+ origem são capturadas mesmo quando usam transferência chunked e não enviam
340
+ `Content-Length`. Corpos acima do limite, com formato inválido ou que não
341
+ podem ser lidos com segurança são omitidos e explicados em `warnings`.
326
342
 
327
343
  `jev_browser_mcp.browser.max_action_timeout_seconds` limita esperas por ações,
328
344
  seletores e condições. `JEV_BROWSER_UPLOAD_ROOT` libera uploads somente dentro
@@ -100,7 +100,10 @@ Cada plano pode usar:
100
100
  o MCP ignora diálogos visualmente ocultos, inclusive os que ficaram no DOM
101
101
  com `opacity: 0`;
102
102
  - `name: ""` com `index` não negativo para controles sem nome. O resultado
103
- inclui um aviso porque a posição pode mudar entre execuções;
103
+ inclui um aviso porque a posição pode mudar entre execuções. O índice é
104
+ zero-based para o mesmo papel e segue o localizador Playwright
105
+ `{role, name: ""}`; controles desabilitados não aparecem no diagnóstico, mas
106
+ continuam contando para que o índice aponte ao controle correto;
104
107
  - `selector` com CSS ou XPath como último recurso. O MCP identifica essa escolha
105
108
  em `warnings`; não é permitido enviar JavaScript nem coordenadas;
106
109
  - `timeout_seconds` opcional em cada etapa, limitado pela configuração central;
@@ -108,8 +111,10 @@ Cada plano pode usar:
108
111
  `blur: true` para desfocar o campo e `sensitive: false` para permitir que o
109
112
  valor apareça nas evidências. Nesse modo, `\n` envia Enter e `\t` envia Tab;
110
113
  - `wait_for_text` e `wait_for_condition` (`network_idle`, `hidden` ou
111
- `text_hidden`); `network_idle` aceita `url_contains` para aguardar só as
112
- requisições correspondentes;
114
+ `text_hidden`). `wait_for_text` aceita `fail_on: {"role":"alert"}` para
115
+ interromper a espera assim que um alerta visível aparecer e incluir seu texto
116
+ no erro. `network_idle` aceita `url_contains` para aguardar só as requisições
117
+ correspondentes;
113
118
  - `press` (o alias legado `press_key` também é aceito) com `PageDown`, `PageUp`,
114
119
  `Home`, `End`, setas, `Enter`, `Escape`, `Tab` ou `Shift+Tab`. Sem alvo,
115
120
  envia a tecla ao elemento focado; com alvo, usa o localizador informado;
@@ -158,9 +163,12 @@ Exemplos para controles legados sem nome acessível:
158
163
  {"action":"click","selector":"#save-document"}
159
164
  ```
160
165
 
161
- O reconhecimento retorna `unnamed_controls` com papel, posição, rótulo mais
162
- próximo e um trecho HTML sanitizado dos controles interativos sem nome. O
163
- snapshot também resume campos de formulário com `id`, `name`, valor, estado
166
+ O reconhecimento retorna `unnamed_controls_initial` e
167
+ `unnamed_controls_final`, cada um com papel, índice Playwright por papel, rótulo
168
+ mais próximo e um trecho HTML sanitizado dos controles visíveis e habilitados
169
+ sem nome. `unnamed_controls` continua disponível como alias da lista final. O
170
+ placeholder conta como nome acessível. O snapshot também resume campos de
171
+ formulário com `id`, `name`, valor, estado
164
172
  desabilitado, rótulo e índice zero-based entre campos de mesmo nome. Valores
165
173
  sensíveis, como senha, token e cartão, são ocultados. `snapshot_include_hidden`
166
174
  é `false` por padrão; defina `true` somente quando precisar inspecionar campos
@@ -321,8 +329,16 @@ esse resultado e `expected_outcome_visible` continua descrevendo somente o
321
329
  texto global. `incomplete` significa que nenhum critério foi comprovado;
322
330
  confiança do Jev não substitui essa verificação. `timings_ms.ready_ms` mede a
323
331
  espera da SPA; `warnings` registra capturas vazias durante transições; e
324
- `failed_step` identifica índice, ação, alvo, timeout e erro resumido quando uma
325
- etapa falha.
332
+ `failed_step` identifica índice, ação, localizador, timeout e erro resumido
333
+ quando uma etapa falha. Cada etapa executada também registra a composição do
334
+ localizador usado e, quando disponível, `resolved_target` com tag, `id`, papel
335
+ e nome acessível do elemento resolvido. Etapas `type` só incluem o valor final
336
+ do campo quando `sensitive: false`.
337
+
338
+ Com `capture_network_error_bodies: true`, respostas JSON de erro da mesma
339
+ origem são capturadas mesmo quando usam transferência chunked e não enviam
340
+ `Content-Length`. Corpos acima do limite, com formato inválido ou que não
341
+ podem ser lidos com segurança são omitidos e explicados em `warnings`.
326
342
 
327
343
  `jev_browser_mcp.browser.max_action_timeout_seconds` limita esperas por ações,
328
344
  seletores e condições. `JEV_BROWSER_UPLOAD_ROOT` libera uploads somente dentro
@@ -102,7 +102,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
102
102
  const schemas = {
103
103
  click: { required: [], optional: [...semanticTarget, "expect_download", "timeout_seconds"] },
104
104
  type: { required: ["text"], optional: [...semanticTarget, "blur", "mode", "sensitive", "timeout_seconds"] },
105
- wait_for_text: { required: ["text"], optional: ["frame", "comment", "timeout_seconds"] },
105
+ wait_for_text: { required: ["text"], optional: ["frame", "comment", "fail_on", "timeout_seconds"] },
106
106
  wait_for_condition: { required: ["condition"], optional: [...semanticTarget, "url_contains", "timeout_seconds"] },
107
107
  press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
108
108
  select_option: { required: ["option"], optional: [...semanticTarget, "timeout_seconds"] },
@@ -232,9 +232,20 @@ export function validateCandidatePlans(candidatePlans, settings) {
232
232
  step.blur = rawStep.blur ?? false;
233
233
  step.sensitive = rawStep.sensitive ?? true;
234
234
  }
235
- if (action === "wait_for_text") {
236
- step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
237
- }
235
+ if (action === "wait_for_text") {
236
+ step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
237
+ if (rawStep.fail_on !== undefined) {
238
+ if (!isRecord(rawStep.fail_on)) throw new JevBrowserError("wait_for_text fail_on must be an object with a role");
239
+ exactKeys(rawStep.fail_on, ["role"], ["name"], "wait_for_text fail_on");
240
+ if (typeof rawStep.fail_on.role !== "string" || !ASSERT_ROLES.has(rawStep.fail_on.role)) {
241
+ throw new JevBrowserError(`wait_for_text fail_on role must be one of: ${[...ASSERT_ROLES].join(", ")}`);
242
+ }
243
+ step.failOn = { role: rawStep.fail_on.role };
244
+ if (rawStep.fail_on.name !== undefined) {
245
+ step.failOn.name = requiredText(rawStep.fail_on.name, "fail_on accessible name", settings.jev.maxActionDescriptionChars);
246
+ }
247
+ }
248
+ }
238
249
  if (action === "wait_for_condition") {
239
250
  if (!new Set(["network_idle", "hidden", "text_hidden"]).has(rawStep.condition)) {
240
251
  throw new JevBrowserError("wait_for_condition condition must be network_idle, hidden, or text_hidden");
@@ -569,6 +580,59 @@ async function snapshotPage(page, settings, secrets, snapshotScope = "body", { a
569
580
  return compactSnapshot(redact(combined, secrets), settings.jev.maxSnapshotChars);
570
581
  }
571
582
 
583
+ async function collectUnnamedControls(root, roles, settings, secrets) {
584
+ const unnamedControls = [];
585
+ for (const role of roles) {
586
+ if (unnamedControls.length >= settings.jev.maxDiagnosticItems) break;
587
+ const locator = root.getByRole(role, { name: "", exact: true });
588
+ const controls = await locator.evaluateAll((elements, maxChars) => {
589
+ const textOf = (element) => (element?.innerText || element?.textContent || "").replace(/\s+/g, " ").trim();
590
+ const nearestLabel = (element) => {
591
+ const labels = [...element.ownerDocument.querySelectorAll("label")]
592
+ .filter((label) => label.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING).reverse();
593
+ for (const label of labels) {
594
+ const controlsBetween = [...element.ownerDocument.querySelectorAll("input,textarea,select,[role=textbox],[role=combobox]")]
595
+ .some((control) => (label.compareDocumentPosition(control) & Node.DOCUMENT_POSITION_FOLLOWING)
596
+ && (control.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING));
597
+ if (!controlsBetween) return textOf(label);
598
+ }
599
+ return "";
600
+ };
601
+ const allowedAttributes = new Set(["id", "name", "type", "title", "aria-label", "aria-labelledby", "placeholder", "role", "data-testid", "href", "alt", "disabled"]);
602
+ const safeHtml = (element) => {
603
+ const clone = element.cloneNode(true);
604
+ for (const node of [clone, ...clone.querySelectorAll("*")]) {
605
+ for (const attribute of [...node.attributes]) if (!allowedAttributes.has(attribute.name.toLowerCase())) node.removeAttribute(attribute.name);
606
+ if (node.matches("input,textarea")) {
607
+ node.removeAttribute("value");
608
+ if (node.matches("textarea")) node.textContent = "";
609
+ }
610
+ }
611
+ clone.querySelectorAll("script,style").forEach((node) => node.remove());
612
+ return clone.outerHTML.replace(/\s+/g, " ").slice(0, maxChars);
613
+ };
614
+ return elements.map((element, index) => ({
615
+ index,
616
+ disabled: Boolean(element.disabled || element.matches(":disabled") || element.getAttribute("aria-disabled") === "true"),
617
+ nearestLabel: nearestLabel(element),
618
+ html: safeHtml(element),
619
+ }));
620
+ }, settings.jev.maxDiagnosticChars);
621
+ for (const control of controls) {
622
+ if (control.disabled) continue;
623
+ unnamedControls.push({
624
+ role,
625
+ index: control.index,
626
+ name: "",
627
+ nearest_label: redact(control.nearestLabel, secrets),
628
+ html: redact(control.html, secrets),
629
+ });
630
+ if (unnamedControls.length >= settings.jev.maxDiagnosticItems) break;
631
+ }
632
+ }
633
+ return unnamedControls;
634
+ }
635
+
572
636
  async function enrichSnapshot(page, snapshot, settings, secrets, snapshotScope, includeHidden) {
573
637
  let root;
574
638
  try {
@@ -603,6 +667,7 @@ async function enrichSnapshot(page, snapshot, settings, secrets, snapshotScope,
603
667
  };
604
668
  const accessibleName = (element) => element.getAttribute("aria-label") || referencedName(element)
605
669
  || [...(element.labels || [])].map(textOf).filter(Boolean).join(" ")
670
+ || (element.matches("input,textarea") ? element.getAttribute("placeholder") : "")
606
671
  || element.getAttribute("alt") || (element.matches("a,button,[role=button],[role=link],[role=menuitem]") ? textOf(element) : "")
607
672
  || element.getAttribute("title") || "";
608
673
  const sensitiveField = (element) => {
@@ -642,16 +707,21 @@ async function enrichSnapshot(page, snapshot, settings, secrets, snapshotScope,
642
707
  clone.querySelectorAll("script,style").forEach((node) => node.remove());
643
708
  return clone.outerHTML.replace(/\s+/g, " ").slice(0, limits.maxChars);
644
709
  };
645
- const interactive = [...scope.querySelectorAll("a,button,input:not([type=hidden]),select,textarea,[role=button],[role=link],[role=menuitem]")]
646
- .filter((element) => limits.includeHidden || isVisible(element));
647
- const unnamed = interactive.filter((element) => !accessibleName(element).trim()).slice(0, limits.maxItems)
648
- .map((element, index) => ({
649
- role: element.getAttribute("role") || element.localName,
650
- index,
651
- nearestLabel: nearestLabel(element),
652
- html: safeHtml(element),
653
- }));
654
- return { formLines, unnamed };
710
+ const roleForInteractive = (element) => {
711
+ const explicitRole = element.getAttribute("role");
712
+ if (explicitRole) return explicitRole;
713
+ if (element.matches("a[href]")) return "link";
714
+ if (element.matches("button,input[type=button],input[type=submit],input[type=reset]")) return "button";
715
+ if (element.matches("input[type=checkbox]")) return "checkbox";
716
+ if (element.matches("input[type=radio]")) return "radio";
717
+ if (element.matches("input[type=search]")) return "searchbox";
718
+ if (element.matches("input:not([type=hidden]),textarea")) return "textbox";
719
+ if (element.matches("select")) return element.multiple ? "listbox" : "combobox";
720
+ return "";
721
+ };
722
+ const interactive = [...scope.querySelectorAll("a,button,input:not([type=hidden]),select,textarea,[role=button],[role=link],[role=menuitem],[role=textbox],[role=searchbox],[role=combobox],[role=checkbox],[role=radio]")];
723
+ const interactiveRoles = [...new Set(interactive.map(roleForInteractive).filter(Boolean))];
724
+ return { formLines, interactiveRoles };
655
725
  }, {
656
726
  includeHidden,
657
727
  maxItems: settings.jev.maxDiagnosticItems,
@@ -659,16 +729,13 @@ async function enrichSnapshot(page, snapshot, settings, secrets, snapshotScope,
659
729
  });
660
730
  const sections = [];
661
731
  if (details.formLines.length) sections.push(`[form controls: ids, names, values (password/token fields redacted), disabled state and same-name index]\n${details.formLines.join("\n")}`);
662
- if (details.unnamed.length) {
663
- sections.push(`[interactive controls without accessible names]\n${details.unnamed.map(({ role, index, nearestLabel, html }) =>
664
- `- ${role} index=${index}; nearest_label=${JSON.stringify(nearestLabel)}; html=${html}`).join("\n")}`);
732
+ const unnamedControls = typeof root.getByRole === "function"
733
+ ? await collectUnnamedControls(root, details.interactiveRoles, settings, secrets)
734
+ : [];
735
+ if (unnamedControls.length) {
736
+ sections.push(`[interactive controls without accessible names; indices match Playwright role/name locators, disabled controls omitted]\n${unnamedControls.map(({ role, index, nearest_label, html }) =>
737
+ `- ${role} name=${JSON.stringify("")} index=${index}; nearest_label=${JSON.stringify(nearest_label)}; html=${html}`).join("\n")}`);
665
738
  }
666
- const unnamedControls = details.unnamed.map(({ role, index, nearestLabel, html }) => ({
667
- role,
668
- index,
669
- nearest_label: redact(nearestLabel, secrets),
670
- html: redact(html, secrets),
671
- }));
672
739
  return {
673
740
  snapshot: compactSnapshot(redact([snapshot, ...sections].join("\n\n"), secrets), settings.jev.maxSnapshotChars),
674
741
  unnamedControls,
@@ -919,6 +986,38 @@ function locatorAt(locator, index) {
919
986
  throw new JevBrowserError(`locator does not support occurrence index ${index}`);
920
987
  }
921
988
 
989
+ async function firstVisibleMatch(locator) {
990
+ const count = await locator.count();
991
+ for (let index = 0; index < count; index += 1) {
992
+ const candidate = locatorAt(locator, index);
993
+ if (await isActuallyVisible(candidate)) return candidate;
994
+ }
995
+ return undefined;
996
+ }
997
+
998
+ async function waitForTextOrFailure(root, step, timeout, settings, secrets) {
999
+ const expected = root.getByText(step.text, { exact: false });
1000
+ const failure = root.getByRole(step.failOn.role, roleOptions(step.failOn));
1001
+ const deadline = performance.now() + timeout;
1002
+ while (true) {
1003
+ const failedMatch = await firstVisibleMatch(failure);
1004
+ if (failedMatch) {
1005
+ const visibleText = await failedMatch.innerText().catch(() => "");
1006
+ const message = sanitizeDiagnosticText(visibleText || step.failOn.name || step.failOn.role, secrets, settings.jev.maxDiagnosticChars);
1007
+ const failureTarget = `role ${step.failOn.role}${step.failOn.name ? ` ${JSON.stringify(redact(step.failOn.name, secrets))}` : ""}`;
1008
+ throw new JevBrowserError(
1009
+ `wait_for_text ${JSON.stringify(redact(step.text, secrets))} stopped because ${failureTarget} appeared: ${JSON.stringify(message)}`,
1010
+ );
1011
+ }
1012
+ if (await firstVisibleMatch(expected)) return;
1013
+ const left = deadline - performance.now();
1014
+ if (left <= 0) {
1015
+ throw new JevBrowserError(`text ${JSON.stringify(redact(step.text, secrets))} has no visible match within ${timeout}ms; ${step.failOn.role} did not appear`);
1016
+ }
1017
+ await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, left)));
1018
+ }
1019
+ }
1020
+
922
1021
  async function visibleIndexes(locator, timeout, description, settings, { allowNone = false } = {}) {
923
1022
  const deadline = performance.now() + timeout;
924
1023
  while (true) {
@@ -1027,11 +1126,70 @@ async function targetLocator(root, step, timeout, settings) {
1027
1126
  }
1028
1127
 
1029
1128
  function describeTarget(step, secrets) {
1030
- if (step.role) return `role ${step.role}${step.name === undefined ? "" : ` ${JSON.stringify(redact(step.name, secrets))}`}`;
1031
- const field = TARGET_LOCATOR_FIELDS.find((candidate) => step[LOCATOR_STEP_KEYS[candidate]] !== undefined);
1032
- if (field) return `${field} ${JSON.stringify(redact(step[LOCATOR_STEP_KEYS[field]], secrets))}`;
1033
- if (step.near) return `target near text ${JSON.stringify(redact(step.near.text, secrets))}`;
1034
- return "focused element";
1129
+ const parts = [];
1130
+ if (step.role) {
1131
+ const name = step.name === undefined ? "" : ` ${JSON.stringify(redact(step.name, secrets))}`;
1132
+ parts.push(`role ${step.role}${name}`);
1133
+ }
1134
+ for (const field of TARGET_LOCATOR_FIELDS) {
1135
+ const value = step[LOCATOR_STEP_KEYS[field]];
1136
+ if (value !== undefined) parts.push(`${field} ${JSON.stringify(redact(value, secrets))}`);
1137
+ }
1138
+ if (step.near) parts.push(`near text ${JSON.stringify(redact(step.near.text, secrets))}`);
1139
+ if (step.within) {
1140
+ parts.push(step.within.rowContaining !== undefined
1141
+ ? `within row_containing ${JSON.stringify(redact(step.within.rowContaining, secrets))}`
1142
+ : `within ${step.within.role}${step.within.name === undefined ? "" : ` ${JSON.stringify(redact(step.within.name, secrets))}`}`);
1143
+ }
1144
+ if (step.index !== undefined) parts.push(`index ${step.index}`);
1145
+ return parts.length ? parts.join(" + ") : "focused element";
1146
+ }
1147
+
1148
+ async function describeResolvedTarget(locator, step, secrets, settings) {
1149
+ if (typeof locator?.evaluate !== "function") return undefined;
1150
+ try {
1151
+ const includeValue = step.action === "type" && step.sensitive === false;
1152
+ const resolved = await locator.evaluate((element, includeFieldValue) => {
1153
+ const textOf = (node) => (node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim();
1154
+ const referenced = (element.getAttribute("aria-labelledby") || "").trim().split(/\s+/).filter(Boolean)
1155
+ .map((id) => textOf(element.ownerDocument.getElementById(id))).filter(Boolean).join(" ");
1156
+ const labels = [...(element.labels || [])].map(textOf).filter(Boolean).join(" ");
1157
+ const controlPlaceholder = element.matches("input,textarea,select") ? element.getAttribute("placeholder") || "" : "";
1158
+ const textAlternative = element.matches("a,button,[role=button],[role=link],[role=menuitem]") ? textOf(element) : "";
1159
+ const role = element.getAttribute("role")
1160
+ || (element.matches("a[href]") ? "link"
1161
+ : element.matches("button,input[type=button],input[type=submit],input[type=reset]") ? "button"
1162
+ : element.matches("input[type=checkbox]") ? "checkbox"
1163
+ : element.matches("input[type=radio]") ? "radio"
1164
+ : element.matches("input[type=search]") ? "searchbox"
1165
+ : element.matches("select") ? "combobox"
1166
+ : element.matches("input,textarea") ? "textbox" : "");
1167
+ const result = {
1168
+ tag: element.localName,
1169
+ id: element.id || "",
1170
+ role,
1171
+ accessible_name: element.getAttribute("aria-label") || referenced || labels || controlPlaceholder
1172
+ || element.getAttribute("alt") || textAlternative || element.getAttribute("title") || "",
1173
+ };
1174
+ if (includeFieldValue && "value" in element) result.value = String(element.value ?? "");
1175
+ return result;
1176
+ }, includeValue);
1177
+ if (step.role) resolved.role = step.role;
1178
+ resolved.id = redact(resolved.id, secrets);
1179
+ resolved.accessible_name = redact(String(resolved.accessible_name).slice(0, settings.jev.maxDiagnosticChars), secrets);
1180
+ if (Object.hasOwn(resolved, "value")) {
1181
+ resolved.value = redact(resolved.value.slice(0, settings.browser.maxTextEntryChars), secrets);
1182
+ }
1183
+ return resolved;
1184
+ } catch {
1185
+ return undefined;
1186
+ }
1187
+ }
1188
+
1189
+ function targetEvidence(step, secrets, resolvedTarget) {
1190
+ const evidence = { target: describeTarget(step, secrets) };
1191
+ if (resolvedTarget) evidence.resolved_target = resolvedTarget;
1192
+ return evidence;
1035
1193
  }
1036
1194
 
1037
1195
  async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
@@ -1253,8 +1411,9 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1253
1411
  const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
1254
1412
  if (step.action === "wait_for_text") {
1255
1413
  const locator = root.getByText(step.text, { exact: false });
1256
- await visibleIndexes(locator, timeout, `text ${JSON.stringify(redact(step.text, secrets))}`, settings);
1257
- return { action: step.action };
1414
+ if (step.failOn) await waitForTextOrFailure(root, step, timeout, settings, secrets);
1415
+ else await visibleIndexes(locator, timeout, `text ${JSON.stringify(redact(step.text, secrets))}`, settings);
1416
+ return { action: step.action, target: `text ${JSON.stringify(redact(step.text, secrets))}` };
1258
1417
  }
1259
1418
  if (step.action === "wait_for_condition") {
1260
1419
  if (step.condition === "network_idle") {
@@ -1273,16 +1432,24 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1273
1432
  const locator = await targetLocator(root, step, timeout, settings);
1274
1433
  await waitForLocatorHidden(locator, timeout, `hidden condition ${describeTarget(step, secrets)}`, settings, step.index);
1275
1434
  }
1276
- return { action: step.action, condition: step.condition };
1435
+ return {
1436
+ action: step.action,
1437
+ condition: step.condition,
1438
+ ...(step.condition === "network_idle"
1439
+ ? (step.urlContains ? { url_contains: redact(step.urlContains, secrets) } : {})
1440
+ : { target: describeTarget(step, secrets) }),
1441
+ };
1277
1442
  }
1278
1443
  if (step.action === "press") {
1444
+ let evidence = {};
1279
1445
  if (step.role || step.name !== undefined || hasLocatorField(step) || step.near) {
1280
1446
  const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1447
+ evidence = targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings));
1281
1448
  await control.press(step.key, { timeout });
1282
1449
  } else {
1283
1450
  await page.keyboard.press(step.key, { timeout });
1284
1451
  }
1285
- return { action: step.action, key: step.key };
1452
+ return { action: step.action, key: step.key, ...evidence };
1286
1453
  }
1287
1454
  if (step.action === "like_comment" || step.action === "unlike_comment") {
1288
1455
  return { action: step.action, result: await reactToComment(root, step, timeout, settings) };
@@ -1300,6 +1467,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1300
1467
  if (step.action === "upload_file") {
1301
1468
  const files = await uploadFiles(step, settings);
1302
1469
  const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1470
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1303
1471
  if (step.target === "input") {
1304
1472
  await control.setInputFiles(files, { timeout });
1305
1473
  } else {
@@ -1313,23 +1481,34 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1313
1481
  await control.drop({ files }, { timeout });
1314
1482
  }
1315
1483
  }
1316
- return { action: step.action, target: step.target, file_count: files.length };
1484
+ return {
1485
+ action: step.action,
1486
+ target: step.target,
1487
+ target_locator: describeTarget(step, secrets),
1488
+ ...(resolvedTarget ? { resolved_target: resolvedTarget } : {}),
1489
+ file_count: files.length,
1490
+ };
1317
1491
  }
1318
1492
  if (step.action === "assert_hidden") {
1319
1493
  const locator = await targetLocator(root, step, timeout, settings);
1320
1494
  await waitForLocatorHidden(locator, timeout, `assert_hidden ${describeTarget(step, secrets)}`, settings, step.index);
1321
- return { action: step.action, asserted: true };
1495
+ return { action: step.action, asserted: true, target: describeTarget(step, secrets) };
1322
1496
  }
1323
1497
  if (step.action === "assert_visible") {
1324
1498
  const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1325
1499
  if (!(await isActuallyVisible(control))) throw new JevBrowserError(`assert_visible failed for ${describeTarget(step, secrets)}`);
1326
- return { action: step.action, asserted: true };
1500
+ return {
1501
+ action: step.action,
1502
+ asserted: true,
1503
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
1504
+ };
1327
1505
  }
1328
1506
  const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1329
1507
  if (step.action === "click") {
1330
- if (!step.expectDownload) {
1508
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1509
+ if (!step.expectDownload) {
1331
1510
  await control.click({ timeout });
1332
- return { action: step.action, target: describeTarget(step, secrets) };
1511
+ return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
1333
1512
  }
1334
1513
  let download;
1335
1514
  try {
@@ -1340,13 +1519,14 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1340
1519
  } catch {
1341
1520
  throw new JevBrowserError("click did not produce the expected download before the configured timeout");
1342
1521
  }
1343
- return {
1344
- action: step.action,
1345
- target: describeTarget(step, secrets),
1346
- downloaded_file: await saveDownload(download, settings, downloadLedger),
1347
- };
1522
+ return {
1523
+ action: step.action,
1524
+ ...targetEvidence(step, secrets, resolvedTarget),
1525
+ downloaded_file: await saveDownload(download, settings, downloadLedger),
1526
+ };
1348
1527
  }
1349
1528
  if (step.action === "type") {
1529
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1350
1530
  if (step.mode === "keys") {
1351
1531
  await control.fill("", { timeout });
1352
1532
  const normalizedText = step.text.replace(/\r\n?/g, "\n");
@@ -1361,32 +1541,54 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1361
1541
  await control.fill(step.text, { timeout });
1362
1542
  if (step.blur) await control.press("Tab", { timeout });
1363
1543
  }
1364
- return { action: step.action, role: step.role, name: step.name };
1365
- }
1544
+ if (resolvedTarget && step.sensitive === false) {
1545
+ resolvedTarget.value = redact((await control.inputValue({ timeout })).slice(0, settings.browser.maxTextEntryChars), secrets);
1546
+ }
1547
+ return {
1548
+ action: step.action,
1549
+ role: step.role,
1550
+ name: step.name,
1551
+ ...targetEvidence(step, secrets, resolvedTarget),
1552
+ };
1553
+ }
1366
1554
  if (step.action === "select_option") {
1555
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1367
1556
  await control.selectOption({ label: step.option }, { timeout });
1368
- return { action: step.action, target: describeTarget(step, secrets) };
1369
- }
1370
- if (step.action === "hover") {
1371
- await control.hover({ timeout });
1372
- return { action: step.action, role: step.role, name: step.name };
1373
- }
1374
- if (step.action === "assert_visible") {
1375
- if (!(await control.isVisible())) throw new JevBrowserError(`assert_visible failed for ${step.role} ${JSON.stringify(redact(step.name, secrets))}`);
1376
- return { action: step.action, asserted: true };
1377
- }
1557
+ return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
1558
+ }
1559
+ if (step.action === "hover") {
1560
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1561
+ await control.hover({ timeout });
1562
+ return { action: step.action, role: step.role, name: step.name, ...targetEvidence(step, secrets, resolvedTarget) };
1563
+ }
1564
+ if (step.action === "assert_visible") {
1565
+ if (!(await control.isVisible())) throw new JevBrowserError(`assert_visible failed for ${step.role} ${JSON.stringify(redact(step.name, secrets))}`);
1566
+ return {
1567
+ action: step.action,
1568
+ asserted: true,
1569
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
1570
+ };
1571
+ }
1378
1572
  if (step.action === "assert_text") {
1379
- const actual = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
1380
- const expected = step.expected.replace(/\s+/g, " ").trim();
1381
- if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
1573
+ const actual = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
1574
+ const expected = step.expected.replace(/\s+/g, " ").trim();
1575
+ if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
1382
1576
  throw new JevBrowserError(`assert_text failed for ${describeTarget(step, secrets)}`);
1383
- }
1384
- return { action: step.action, asserted: true };
1385
- }
1386
- if (step.action === "assert_value") {
1387
- const actual = await control.inputValue({ timeout });
1577
+ }
1578
+ return {
1579
+ action: step.action,
1580
+ asserted: true,
1581
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
1582
+ };
1583
+ }
1584
+ if (step.action === "assert_value") {
1585
+ const actual = await control.inputValue({ timeout });
1388
1586
  if (actual !== step.expected) throw new JevBrowserError(`assert_value failed for ${describeTarget(step, secrets)}`);
1389
- return { action: step.action, asserted: true };
1587
+ return {
1588
+ action: step.action,
1589
+ asserted: true,
1590
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
1591
+ };
1390
1592
  }
1391
1593
  throw new JevBrowserError("unsupported browser action");
1392
1594
  }
@@ -1429,7 +1631,7 @@ function sanitizeDiagnosticText(value, secrets, limit) {
1429
1631
  }
1430
1632
 
1431
1633
  function createDiagnostics(page, options, settings, secrets) {
1432
- const diagnostics = { console_errors: [], network_failures: [] };
1634
+ const diagnostics = { console_errors: [], network_failures: [], warnings: [] };
1433
1635
  const pendingBodies = [];
1434
1636
  const limit = settings.jev.maxDiagnosticItems;
1435
1637
  const messageLimit = settings.jev.maxDiagnosticChars;
@@ -1441,26 +1643,50 @@ function createDiagnostics(page, options, settings, secrets) {
1441
1643
  addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(error.message || error.name, secrets, messageLimit), limit);
1442
1644
  };
1443
1645
  const addResponseMessage = async (response, entry) => {
1646
+ const warn = (reason) => addDiagnostic(
1647
+ diagnostics.warnings,
1648
+ `network error response body omitted for ${entry.url}: ${reason}`,
1649
+ limit,
1650
+ );
1444
1651
  try {
1445
1652
  const responseUrl = new URL(response.url());
1446
1653
  const pageUrl = new URL(page.url());
1447
1654
  if (responseUrl.origin !== pageUrl.origin) return;
1448
1655
  const headers = await response.headers();
1449
1656
  const contentType = String(headers["content-type"] || "").toLowerCase();
1450
- const contentLength = Number(headers["content-length"]);
1451
- if (!contentType.includes("application/json") || !Number.isSafeInteger(contentLength)
1452
- || contentLength < 0 || contentLength > settings.browser.maxNetworkErrorBodyBytes) return;
1657
+ if (!contentType.includes("application/json")) {
1658
+ warn("content-type is not application/json");
1659
+ return;
1660
+ }
1661
+ const contentLengthHeader = headers["content-length"];
1662
+ if (contentLengthHeader !== undefined) {
1663
+ const contentLength = Number(contentLengthHeader);
1664
+ if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
1665
+ warn("Content-Length is invalid");
1666
+ return;
1667
+ }
1668
+ if (contentLength > settings.browser.maxNetworkErrorBodyBytes) {
1669
+ warn("declared Content-Length exceeds the configured limit");
1670
+ return;
1671
+ }
1672
+ }
1453
1673
  const body = await withTimeout(
1454
1674
  response.body(),
1455
1675
  settings.browser.actionTimeoutMs,
1456
1676
  "error response body read timed out",
1457
1677
  );
1458
- if (body.length > settings.browser.maxNetworkErrorBodyBytes) return;
1678
+ if (body.length > settings.browser.maxNetworkErrorBodyBytes) {
1679
+ warn("received body exceeds the configured limit");
1680
+ return;
1681
+ }
1459
1682
  const payload = JSON.parse(body.toString("utf8"));
1460
- if (!isRecord(payload) || typeof payload.message !== "string") return;
1683
+ if (!isRecord(payload) || typeof payload.message !== "string") {
1684
+ warn("JSON body does not contain a text message");
1685
+ return;
1686
+ }
1461
1687
  entry.message = sanitizeDiagnosticText(payload.message, secrets, settings.browser.maxNetworkErrorMessageChars);
1462
1688
  } catch {
1463
- // A diagnostic body is optional; malformed, oversized, or late bodies do not fail the flow.
1689
+ warn("body could not be read or parsed safely");
1464
1690
  }
1465
1691
  };
1466
1692
  const onResponse = (response) => {
@@ -1518,6 +1744,12 @@ function failedStepDetails(step, index, error, settings, secrets) {
1518
1744
  };
1519
1745
  if (step.role !== undefined) details.role = step.role;
1520
1746
  if (step.name !== undefined) details.name = redact(step.name, secrets);
1747
+ if (step.role || hasLocatorField(step) || step.near || step.within) details.target = describeTarget(step, secrets);
1748
+ if (step.action === "wait_for_text") details.text = redact(step.text, secrets);
1749
+ if (step.failOn) {
1750
+ details.fail_on = { role: step.failOn.role };
1751
+ if (step.failOn.name !== undefined) details.fail_on.name = redact(step.failOn.name, secrets);
1752
+ }
1521
1753
  details.timeout_ms = step.timeoutMs ?? settings.browser.actionTimeoutMs;
1522
1754
  details.error = stepFailureText(error?.stepCause || error, secrets, settings.jev.maxDiagnosticChars);
1523
1755
  return details;
@@ -1593,6 +1825,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1593
1825
  downloaded_files: [],
1594
1826
  accessibility_audits: [],
1595
1827
  unnamed_controls: [],
1828
+ unnamed_controls_initial: [],
1829
+ unnamed_controls_final: [],
1596
1830
  warnings: [],
1597
1831
  final_snapshot: "",
1598
1832
  };
@@ -1643,6 +1877,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1643
1877
  const enriched = await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
1644
1878
  result.final_snapshot = enriched.snapshot;
1645
1879
  result.unnamed_controls = enriched.unnamedControls;
1880
+ result.unnamed_controls_initial = enriched.unnamedControls;
1881
+ result.unnamed_controls_final = enriched.unnamedControls;
1646
1882
  for (const control of enriched.unnamedControls) {
1647
1883
  addDiagnostic(
1648
1884
  result.warnings,
@@ -1772,9 +2008,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1772
2008
  options.snapshotIncludeHidden,
1773
2009
  );
1774
2010
  snapshot = finalDetails.snapshot;
1775
- result.unnamed_controls = [...result.unnamed_controls, ...finalDetails.unnamedControls]
1776
- .filter((control, index, all) => all.findIndex((candidate) => candidate.role === control.role
1777
- && candidate.html === control.html) === index);
2011
+ result.unnamed_controls_final = finalDetails.unnamedControls;
2012
+ result.unnamed_controls = finalDetails.unnamedControls;
1778
2013
  timings.browserPlanMs = performance.now() - planStarted;
1779
2014
  const visible = containsExpectedOutcome(snapshot, outcome);
1780
2015
  const assertionCount = plan.steps.filter(({ action }) => action.startsWith("assert_")).length;
@@ -1825,6 +2060,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1825
2060
  options.snapshotIncludeHidden,
1826
2061
  );
1827
2062
  result.final_snapshot = enriched.snapshot;
2063
+ result.unnamed_controls_final = enriched.unnamedControls;
1828
2064
  result.unnamed_controls = enriched.unnamedControls;
1829
2065
  } else {
1830
2066
  result.final_snapshot = snapshot;
@@ -1865,8 +2101,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1865
2101
  }
1866
2102
  if (tracePath) result.trace_path = tracePath;
1867
2103
  if (artifactErrors.length) result.artifact_errors = artifactErrors;
1868
- result.console_errors = capture.diagnostics.console_errors;
1869
- result.network_failures = capture.diagnostics.network_failures;
2104
+ result.console_errors = capture.diagnostics.console_errors;
2105
+ result.network_failures = capture.diagnostics.network_failures;
2106
+ result.warnings.push(...capture.diagnostics.warnings);
1870
2107
  result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
1871
2108
  return redactObject(result, secrets);
1872
2109
  }
@@ -132,7 +132,7 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
132
132
  server.registerTool(
133
133
  "run_browser_flow",
134
134
  {
135
- description: "Run a bounded screen flow in one warm Playwright session. Supports role/name and label, placeholder, title, text, test_id, CSS/XPath fallback selectors, relative row/dialog scopes, press keys, SPA readiness, per-step timeouts, same-origin page reuse, key-based typing, filtered network-idle and hidden-state waits, assertions, uploads, bounded downloads, axe audits, unnamed-control diagnostics, rich form snapshots, and optional same-origin JSON error messages. All plan steps run before expected_outcome is checked unless stop_on_expected is enabled. Jev chooses among supplied plans; one plan skips the decision call when fast_path is enabled.",
135
+ description: "Run a bounded screen flow in one warm Playwright session. Supports role/name and label, placeholder, title, text, test_id, CSS/XPath fallback selectors, relative row/dialog scopes, press keys, SPA readiness, per-step timeouts, same-origin page reuse, key-based typing, filtered network-idle and hidden-state waits, wait_for_text fail_on alerts, assertions, uploads, bounded downloads, axe audits, Playwright-indexed unnamed-control diagnostics split by initial/final screen, resolved locator and element evidence, and bounded same-origin JSON error bodies including chunked responses. All plan steps run before expected_outcome is checked unless stop_on_expected is enabled. Jev chooses among supplied plans; one plan skips the decision call when fast_path is enabled.",
136
136
  inputSchema: {
137
137
  flow: z.string(),
138
138
  initial_url: z.string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegosouzacdv/jev-browser-mcp",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Portable MCP server for bounded Playwright screen flows selected by Jev",
5
5
  "license": "MIT",
6
6
  "repository": {