@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`)
|
|
112
|
-
|
|
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 `
|
|
162
|
-
|
|
163
|
-
|
|
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,
|
|
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
|
package/docs/jev-browser-mcp.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`)
|
|
112
|
-
|
|
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 `
|
|
162
|
-
|
|
163
|
-
|
|
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,
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
|
1257
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
1508
|
+
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
1509
|
+
if (!step.expectDownload) {
|
|
1331
1510
|
await control.click({ timeout });
|
|
1332
|
-
return { action: step.action,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1369
|
-
}
|
|
1370
|
-
if (step.action === "hover") {
|
|
1371
|
-
await control
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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 {
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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 {
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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)
|
|
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")
|
|
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
|
-
|
|
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.
|
|
1776
|
-
|
|
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,
|
|
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(),
|