donobu 5.33.0 → 5.35.0
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/dist/esm/lib/ai/cache/assertCache.d.ts +14 -0
- package/dist/esm/lib/ai/cache/assertCache.js +24 -7
- package/dist/esm/lib/ai/locate/buildLocator.d.ts +5 -1
- package/dist/esm/lib/ai/locate/buildLocator.js +63 -14
- package/dist/esm/lib/ai/locate/locateElement.d.ts +1 -0
- package/dist/esm/lib/ai/locate/locateElement.js +107 -10
- package/dist/esm/lib/ai/locate/locateSchema.d.ts +2 -0
- package/dist/esm/lib/ai/locate/locateSchema.js +9 -1
- package/dist/esm/lib/ai/locate/locateTypes.d.ts +59 -2
- package/dist/esm/lib/page/DonobuExtendedPage.d.ts +24 -0
- package/dist/esm/lib/page/extendPage.js +125 -27
- package/dist/esm/managers/ToolManager.js +3 -0
- package/dist/esm/models/ToolCallContext.d.ts +11 -0
- package/dist/esm/tools/AssertTool.js +32 -2
- package/dist/lib/ai/cache/assertCache.d.ts +14 -0
- package/dist/lib/ai/cache/assertCache.js +24 -7
- package/dist/lib/ai/locate/buildLocator.d.ts +5 -1
- package/dist/lib/ai/locate/buildLocator.js +63 -14
- package/dist/lib/ai/locate/locateElement.d.ts +1 -0
- package/dist/lib/ai/locate/locateElement.js +107 -10
- package/dist/lib/ai/locate/locateSchema.d.ts +2 -0
- package/dist/lib/ai/locate/locateSchema.js +9 -1
- package/dist/lib/ai/locate/locateTypes.d.ts +59 -2
- package/dist/lib/page/DonobuExtendedPage.d.ts +24 -0
- package/dist/lib/page/extendPage.js +125 -27
- package/dist/managers/ToolManager.js +3 -0
- package/dist/models/ToolCallContext.d.ts +11 -0
- package/dist/tools/AssertTool.js +32 -2
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ exports.locateElement = locateElement;
|
|
|
4
4
|
const v4_1 = require("zod/v4");
|
|
5
5
|
const Logger_1 = require("../../../utils/Logger");
|
|
6
6
|
const PlaywrightUtils_1 = require("../../../utils/PlaywrightUtils");
|
|
7
|
+
const TemplateInterpolator_1 = require("../../../utils/TemplateInterpolator");
|
|
7
8
|
const buildLocator_1 = require("./buildLocator");
|
|
8
9
|
const domSnapshot_1 = require("./domSnapshot");
|
|
9
10
|
const LocateException_1 = require("./LocateException");
|
|
@@ -27,14 +28,15 @@ const SNIPPET_MAX_CHARS = 200;
|
|
|
27
28
|
* callers can cache the result for deterministic replay.
|
|
28
29
|
*/
|
|
29
30
|
async function locateElement(page, description, gptClient, options) {
|
|
31
|
+
const envData = options?.envData;
|
|
30
32
|
const screenshot = await PlaywrightUtils_1.PlaywrightUtils.takeViewportScreenshot(page);
|
|
31
33
|
const domSnapshot = await (0, domSnapshot_1.captureDomSnapshot)(page);
|
|
32
34
|
Logger_1.appLogger.debug(`locate: DOM snapshot captured (${domSnapshot.html.length} chars, ${domSnapshot.omittedCount} nodes omitted)`);
|
|
33
|
-
const systemMessage = buildSystemMessage(page.url(), await page.title());
|
|
35
|
+
const systemMessage = buildSystemMessage(page.url(), await page.title(), description, envData);
|
|
34
36
|
const userMessage = buildUserMessage(description, screenshot, domSnapshot.html);
|
|
35
37
|
// First attempt
|
|
36
38
|
const firstResult = await callLlm(gptClient, systemMessage, userMessage, options?.signal);
|
|
37
|
-
const firstLocator = (0, buildLocator_1.buildLocator)(page, firstResult);
|
|
39
|
+
const firstLocator = (0, buildLocator_1.buildLocator)(page, firstResult, envData);
|
|
38
40
|
const firstCount = await safeCount(firstLocator);
|
|
39
41
|
Logger_1.appLogger.debug(`locate: first attempt matched ${firstCount} element(s)`);
|
|
40
42
|
if (firstCount === 1) {
|
|
@@ -42,7 +44,7 @@ async function locateElement(page, description, gptClient, options) {
|
|
|
42
44
|
}
|
|
43
45
|
// Disambiguation: small number of matches — show snippets and let LLM pick
|
|
44
46
|
if (firstCount > 1 && firstCount <= DISAMBIGUATE_THRESHOLD) {
|
|
45
|
-
return await disambiguate(page, description, gptClient, firstLocator, firstResult, firstCount, options?.signal);
|
|
47
|
+
return await disambiguate(page, description, gptClient, firstLocator, firstResult, firstCount, envData, options?.signal);
|
|
46
48
|
}
|
|
47
49
|
// Retry: zero matches or too many
|
|
48
50
|
const previousAttempt = summarizeLocateResult(firstResult);
|
|
@@ -58,14 +60,14 @@ async function locateElement(page, description, gptClient, options) {
|
|
|
58
60
|
: `Your locator matched ${firstCount} elements, which is too many to disambiguate. Your previous attempt was: ${previousAttempt}. Write a more specific locator.`;
|
|
59
61
|
const retryMessage = buildRetryMessage(description, feedback, screenshot, retryDomHtml);
|
|
60
62
|
const retryResult = await callLlm(gptClient, systemMessage, retryMessage, options?.signal);
|
|
61
|
-
const retryLocator = (0, buildLocator_1.buildLocator)(page, retryResult);
|
|
63
|
+
const retryLocator = (0, buildLocator_1.buildLocator)(page, retryResult, envData);
|
|
62
64
|
const retryCount = await safeCount(retryLocator);
|
|
63
65
|
Logger_1.appLogger.debug(`locate: retry matched ${retryCount} element(s)`);
|
|
64
66
|
if (retryCount === 1) {
|
|
65
67
|
return { locator: retryLocator, result: retryResult };
|
|
66
68
|
}
|
|
67
69
|
if (retryCount > 1 && retryCount <= DISAMBIGUATE_THRESHOLD) {
|
|
68
|
-
return await disambiguate(page, description, gptClient, retryLocator, retryResult, retryCount, options?.signal);
|
|
70
|
+
return await disambiguate(page, description, gptClient, retryLocator, retryResult, retryCount, envData, options?.signal);
|
|
69
71
|
}
|
|
70
72
|
// Give up
|
|
71
73
|
const reason = retryCount === 0 ? 'no_matches' : 'too_many_matches';
|
|
@@ -77,7 +79,7 @@ async function locateElement(page, description, gptClient, options) {
|
|
|
77
79
|
* Show HTML snippets of each match to the LLM and ask it to pick the
|
|
78
80
|
* correct one. Returns the original locator with `.nth(n)` appended.
|
|
79
81
|
*/
|
|
80
|
-
async function disambiguate(page, description, gptClient, locator, locateResult, count, signal) {
|
|
82
|
+
async function disambiguate(page, description, gptClient, locator, locateResult, count, envData, signal) {
|
|
81
83
|
const snippets = [];
|
|
82
84
|
for (let i = 0; i < count; i++) {
|
|
83
85
|
const nth = locator.nth(i);
|
|
@@ -111,6 +113,12 @@ async function disambiguate(page, description, gptClient, locator, locateResult,
|
|
|
111
113
|
.max(count - 1)
|
|
112
114
|
.describe('Zero-based index of the element that best matches the description.'),
|
|
113
115
|
});
|
|
116
|
+
// Disambiguation output is just an index — never cached and never fed back
|
|
117
|
+
// through `buildLocator`. Show the LLM the resolved description so it can
|
|
118
|
+
// match candidate HTML directly without doing mental env-var substitution.
|
|
119
|
+
const resolvedDescription = envData && description.includes('{{')
|
|
120
|
+
? (0, TemplateInterpolator_1.interpolateString)(description, { env: envData, calls: [] })
|
|
121
|
+
: description;
|
|
114
122
|
const systemMsg = {
|
|
115
123
|
type: 'system',
|
|
116
124
|
text: `You are resolving an ambiguous element lookup. The user described an element and your locator matched ${count} candidates. Choose the one that best matches the description.`,
|
|
@@ -120,7 +128,7 @@ async function disambiguate(page, description, gptClient, locator, locateResult,
|
|
|
120
128
|
items: [
|
|
121
129
|
{
|
|
122
130
|
type: 'text',
|
|
123
|
-
text: `Description: "${
|
|
131
|
+
text: `Description: "${resolvedDescription}"\n\nCandidates:\n${snippetText}\n\nReturn the index of the best match.`,
|
|
124
132
|
},
|
|
125
133
|
],
|
|
126
134
|
};
|
|
@@ -131,7 +139,7 @@ async function disambiguate(page, description, gptClient, locator, locateResult,
|
|
|
131
139
|
nth: resp.output.index,
|
|
132
140
|
};
|
|
133
141
|
return {
|
|
134
|
-
locator: (0, buildLocator_1.buildLocator)(page, disambiguatedResult),
|
|
142
|
+
locator: (0, buildLocator_1.buildLocator)(page, disambiguatedResult, envData),
|
|
135
143
|
result: disambiguatedResult,
|
|
136
144
|
};
|
|
137
145
|
}
|
|
@@ -139,7 +147,54 @@ async function callLlm(gptClient, systemMessage, userMessage, signal) {
|
|
|
139
147
|
const resp = await gptClient.getStructuredOutput([systemMessage, userMessage], locateSchema_1.LocateResultSchema, { signal });
|
|
140
148
|
return resp.output;
|
|
141
149
|
}
|
|
142
|
-
function buildSystemMessage(pageUrl, pageTitle) {
|
|
150
|
+
function buildSystemMessage(pageUrl, pageTitle, description, envData) {
|
|
151
|
+
// Only annotate the prompt with env-var guidance when the raw description
|
|
152
|
+
// actually references at least one provided env var. Keeps the prompt small
|
|
153
|
+
// for the common case.
|
|
154
|
+
const envEntries = Object.entries(envData ?? {});
|
|
155
|
+
const referencedEnvEntries = envEntries.filter(([name]) => description.includes(`{{$.env.${name}}}`));
|
|
156
|
+
const envBlock = referencedEnvEntries.length > 0
|
|
157
|
+
? `
|
|
158
|
+
|
|
159
|
+
The user's description contains environment variable references using the syntax
|
|
160
|
+
\`{{$.env.NAME}}\`. To keep cached locators valid across runs with different env
|
|
161
|
+
values, you MUST emit those same placeholders in any LocatorStep \`text\`,
|
|
162
|
+
\`name\`, or \`testId\` field whose contents come from an env var. Do NOT bake
|
|
163
|
+
the literal current value into the step.
|
|
164
|
+
|
|
165
|
+
Original (uninterpolated) description: "${description}"
|
|
166
|
+
|
|
167
|
+
Current env mapping (use these to identify which substrings on the page came
|
|
168
|
+
from which env var, then emit the placeholder rather than the literal):
|
|
169
|
+
${referencedEnvEntries.map(([name, value]) => ` - {{$.env.${name}}} = ${JSON.stringify(value)}`).join('\n')}
|
|
170
|
+
|
|
171
|
+
Hard rules for env-var emission:
|
|
172
|
+
- Use placeholders ONLY in \`text\`, \`name\`, or \`testId\` fields.
|
|
173
|
+
- NEVER emit \`{{$.env.*}}\` inside \`selector\` (CSS/XPath) — interpolating
|
|
174
|
+
raw values into a CSS selector can produce invalid syntax. Use a semantic
|
|
175
|
+
locator (getByRole/getByText/getByLabel/getByPlaceholder/getByTestId)
|
|
176
|
+
instead when an env-derived value is involved.
|
|
177
|
+
- NEVER emit \`{{$.env.*}}\` inside any \`frames[]\` entry (iframe selectors
|
|
178
|
+
or iframe \`name\` attributes are not env-driven).
|
|
179
|
+
|
|
180
|
+
Examples:
|
|
181
|
+
- Description "The user row for {{$.env.TEST_EMAIL}}", TEST_EMAIL="alice@x.com",
|
|
182
|
+
page text shows "alice@x.com" →
|
|
183
|
+
[{ method: "getByText", text: "{{$.env.TEST_EMAIL}}" }]
|
|
184
|
+
- Description "The {{$.env.PROJECT_NAME}} tab", PROJECT_NAME="Apollo" →
|
|
185
|
+
[{ method: "getByRole", role: "tab", name: "{{$.env.PROJECT_NAME}}" }]
|
|
186
|
+
- Description "The submit button" (no env vars referenced) → emit literal text
|
|
187
|
+
as you normally would.
|
|
188
|
+
|
|
189
|
+
Combining env vars with regex: env interpolation runs BEFORE regex compilation,
|
|
190
|
+
so you can mix them. Prefer this when the env value should be matched alongside
|
|
191
|
+
dynamic page content. Example — description "The row for {{$.env.USER}} with
|
|
192
|
+
their score", USER="alice" →
|
|
193
|
+
[{ method: "getByText", text: "alice — \\\\d+ pts", textIsRegex: true }]
|
|
194
|
+
(Here the AI substituted the env value because it's part of a regex pattern;
|
|
195
|
+
the placeholder syntax also works — \`text: "{{$.env.USER}} — \\\\d+ pts"\` —
|
|
196
|
+
and is preferred when you want cache stability across env value changes.)`
|
|
197
|
+
: '';
|
|
143
198
|
return {
|
|
144
199
|
type: 'system',
|
|
145
200
|
text: `You are a Playwright locator expert. Given a viewport screenshot and a pruned DOM snapshot of a webpage, return a structured locator that targets the element matching the user's description.
|
|
@@ -151,8 +206,50 @@ Rules:
|
|
|
151
206
|
- If the element is inside an iframe, specify the frame(s) in the "frames" field.
|
|
152
207
|
- Do NOT set "nth" unless you are certain the chain matches multiple elements and you know which index is correct. When unsure, omit it — the system will handle disambiguation.
|
|
153
208
|
|
|
209
|
+
Stability rules — locators are CACHED and replayed across runs. The page may
|
|
210
|
+
change between runs (vote counts increment, "3 hours ago" becomes "5 hours ago",
|
|
211
|
+
new posts shift positions, prices fluctuate). Choose locators that survive these
|
|
212
|
+
drifts:
|
|
213
|
+
|
|
214
|
+
- POSITIONAL DESCRIPTIONS: when the description references position ("first",
|
|
215
|
+
"third", "fourth from the top", "last"), translate that into a structural
|
|
216
|
+
chain plus \`nth\` rather than baking position-specific page text into a step.
|
|
217
|
+
Example — "the fourth comments link" should be a locator over ALL comment
|
|
218
|
+
links with \`nth: 3\`, not the literal "36 comments" you happen to see today.
|
|
219
|
+
|
|
220
|
+
- DYNAMIC TEXT: if the value you would put into \`name\` or \`text\` looks
|
|
221
|
+
dynamic — contains digits, timestamps, "X ago", "$X.XX", counts, scores,
|
|
222
|
+
vote totals — emit a regex pattern via \`nameIsRegex: true\` (for getByRole)
|
|
223
|
+
or \`textIsRegex: true\` (for getByText/getByLabel/getByPlaceholder) instead
|
|
224
|
+
of the literal value. Anchor the pattern with \`^\` / \`$\` when the whole
|
|
225
|
+
string should match, otherwise it acts as a substring match.
|
|
226
|
+
|
|
227
|
+
- DO NOT combine \`exact: true\` with \`nameIsRegex\`/\`textIsRegex\`. They are
|
|
228
|
+
mutually exclusive — set \`exact\` only for literal-string steps with stable
|
|
229
|
+
fixed labels like "Submit" or "Sign In".
|
|
230
|
+
|
|
231
|
+
- SAFE LITERALS: keep literal values for genuinely stable strings — fixed UI
|
|
232
|
+
labels, button text like "Submit"/"Cancel", section headings, unique
|
|
233
|
+
test-ids. Only escape to regex when stability is at risk.
|
|
234
|
+
|
|
235
|
+
Examples:
|
|
236
|
+
- "The fourth comments link" →
|
|
237
|
+
steps: [{ method: "getByRole", role: "link", name: "\\\\d+\\\\s+comments?$", nameIsRegex: true }]
|
|
238
|
+
nth: 3
|
|
239
|
+
- "The headline of the third story" → structural row selector + nth: 2 (literal name)
|
|
240
|
+
- "The submit button" → literal name: "Submit", optionally exact: true
|
|
241
|
+
- "The price tag for the cart total" →
|
|
242
|
+
steps: [{ method: "getByText", text: "\\\\$\\\\d+(\\\\.\\\\d+)?", textIsRegex: true }]
|
|
243
|
+
- "The 'posted 5 hours ago' label" →
|
|
244
|
+
steps: [{ method: "getByText", text: "posted \\\\d+ (minute|hour|day)s? ago", textIsRegex: true }]
|
|
245
|
+
|
|
246
|
+
Regex format: emit a JS-style regex source string (no leading/trailing slash,
|
|
247
|
+
no flags). Backslashes inside JSON must be doubled (\`\\\\d+\` not \`\\d+\`).
|
|
248
|
+
Invalid patterns silently fall back to literal matching, so prefer simple,
|
|
249
|
+
well-tested patterns.
|
|
250
|
+
|
|
154
251
|
Page URL: ${pageUrl}
|
|
155
|
-
Page title: ${pageTitle}`,
|
|
252
|
+
Page title: ${pageTitle}${envBlock}`,
|
|
156
253
|
};
|
|
157
254
|
}
|
|
158
255
|
function buildUserMessage(description, screenshot, domHtml) {
|
|
@@ -27,6 +27,8 @@ export declare const LocateResultSchema: z.ZodObject<{
|
|
|
27
27
|
testId: z.ZodOptional<z.ZodString>;
|
|
28
28
|
selector: z.ZodOptional<z.ZodString>;
|
|
29
29
|
exact: z.ZodOptional<z.ZodBoolean>;
|
|
30
|
+
nameIsRegex: z.ZodOptional<z.ZodBoolean>;
|
|
31
|
+
textIsRegex: z.ZodOptional<z.ZodBoolean>;
|
|
30
32
|
}, z.core.$strip>>;
|
|
31
33
|
nth: z.ZodOptional<z.ZodNumber>;
|
|
32
34
|
}, z.core.$strip>;
|
|
@@ -46,7 +46,15 @@ const LocatorStepSchema = v4_1.z
|
|
|
46
46
|
exact: v4_1.z
|
|
47
47
|
.boolean()
|
|
48
48
|
.optional()
|
|
49
|
-
.describe('Whether text/name matching should be exact. Applies to getByRole (name), getByText, getByLabel, getByPlaceholder.'),
|
|
49
|
+
.describe('Whether text/name matching should be exact. Applies to getByRole (name), getByText, getByLabel, getByPlaceholder. Mutually exclusive with nameIsRegex / textIsRegex.'),
|
|
50
|
+
nameIsRegex: v4_1.z
|
|
51
|
+
.boolean()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe('Set true when "name" is a regex pattern (compiled via new RegExp(name)). Use this for dynamic accessible names — e.g. "\\d+ comments" matches any "N comments" link. Used with getByRole. Do not combine with exact:true.'),
|
|
54
|
+
textIsRegex: v4_1.z
|
|
55
|
+
.boolean()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('Set true when "text" is a regex pattern (compiled via new RegExp(text)). Use this for dynamic page text — counts, dates, prices, "X ago" timestamps. Used with getByText / getByLabel / getByPlaceholder. Do not combine with exact:true.'),
|
|
50
58
|
})
|
|
51
59
|
.describe('A single Playwright locator step.');
|
|
52
60
|
const FrameStepSchema = v4_1.z
|
|
@@ -20,6 +20,24 @@ export type LocatorStep = {
|
|
|
20
20
|
selector?: string;
|
|
21
21
|
/** Whether text/name matching should be exact. */
|
|
22
22
|
exact?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* When true, `name` is treated as a regex pattern compiled via
|
|
25
|
+
* `new RegExp(name)` rather than a literal string. Mutually exclusive
|
|
26
|
+
* with `exact: true`. Used with `getByRole`.
|
|
27
|
+
*
|
|
28
|
+
* Env-var placeholders are interpolated **before** regex compilation, so
|
|
29
|
+
* `'\\d+ {{$.env.NOUN}}'` with `NOUN='comments'` compiles as
|
|
30
|
+
* `/\d+ comments/`.
|
|
31
|
+
*/
|
|
32
|
+
nameIsRegex?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* When true, `text` is treated as a regex pattern compiled via
|
|
35
|
+
* `new RegExp(text)` rather than a literal string. Mutually exclusive with
|
|
36
|
+
* `exact: true`. Used with `getByText`, `getByLabel`, `getByPlaceholder`.
|
|
37
|
+
*
|
|
38
|
+
* Env-var placeholders are interpolated **before** regex compilation.
|
|
39
|
+
*/
|
|
40
|
+
textIsRegex?: boolean;
|
|
23
41
|
};
|
|
24
42
|
/**
|
|
25
43
|
* Identifies an iframe to scope into before applying {@link LocatorStep}s.
|
|
@@ -49,9 +67,48 @@ export type LocateResult = {
|
|
|
49
67
|
*/
|
|
50
68
|
export type LocateOptions = {
|
|
51
69
|
gptClient?: GptClient | Exclude<LanguageModel, string>;
|
|
52
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Timeout in milliseconds for the entire locate operation (default: 30 000).
|
|
72
|
+
*
|
|
73
|
+
* On cache hit this budgets the hydration patience window — the cached
|
|
74
|
+
* locator gets up to this long to attach to a matching element before the
|
|
75
|
+
* cache is treated as stale and the AI is re-run. On cache miss (or
|
|
76
|
+
* stale-cache fallthrough) this budgets the AI call. Whatever the cache
|
|
77
|
+
* path consumes is deducted from the AI path's remaining budget; the total
|
|
78
|
+
* never exceeds `timeout`.
|
|
79
|
+
*/
|
|
53
80
|
timeout?: number;
|
|
54
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Whether to use the on-disk cache. Defaults to true.
|
|
83
|
+
*
|
|
84
|
+
* Cached `LocateResult` step fields preserve `{{$.env.*}}` placeholders for
|
|
85
|
+
* any value that came from an env var, so changing an env value between
|
|
86
|
+
* runs replays the same cached locator with the new value rather than
|
|
87
|
+
* re-invoking the AI.
|
|
88
|
+
*/
|
|
55
89
|
cache?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Explicit environment variable names (in addition to the heuristically
|
|
92
|
+
* derived ones) that the description may read via `{{$.env.*}}`
|
|
93
|
+
* interpolations.
|
|
94
|
+
*/
|
|
95
|
+
envVars?: string[];
|
|
96
|
+
/**
|
|
97
|
+
* Explicitly supply environment variable values that amend (or override)
|
|
98
|
+
* the environment observed by this `page.ai.locate` call. Keys are merged
|
|
99
|
+
* with any names derived from {@link LocateOptions.envVars} and from
|
|
100
|
+
* `{{$.env.*}}` interpolations in the description.
|
|
101
|
+
*
|
|
102
|
+
* - A `string` value sets or overrides the variable for this invocation.
|
|
103
|
+
* - An `undefined` value *removes* the variable, even if it would
|
|
104
|
+
* otherwise be resolved from persistence.
|
|
105
|
+
*
|
|
106
|
+
* Only the **names** (keys) influence cache lookup; changing a value
|
|
107
|
+
* replays the cached locator with the new value via `{{$.env.*}}`
|
|
108
|
+
* placeholder substitution rather than busting the cache. If a referenced
|
|
109
|
+
* env var is absent at replay, the placeholder is left literal — the
|
|
110
|
+
* locator will then match zero elements and fail loudly.
|
|
111
|
+
*/
|
|
112
|
+
envVals?: Record<string, string | undefined>;
|
|
56
113
|
};
|
|
57
114
|
//# sourceMappingURL=locateTypes.d.ts.map
|
|
@@ -40,8 +40,32 @@ export type AssertOptions = {
|
|
|
40
40
|
* and generates equivalent Playwright code which is cached. Subsequent
|
|
41
41
|
* runs execute the cached code directly, skipping the AI call entirely.
|
|
42
42
|
* Defaults to `true`.
|
|
43
|
+
*
|
|
44
|
+
* Cached steps preserve `{{$.env.*}}` placeholders for any value that came
|
|
45
|
+
* from an env var, so changing an env value between runs replays the same
|
|
46
|
+
* cached steps with the new value rather than re-invoking the AI.
|
|
43
47
|
*/
|
|
44
48
|
cache?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Explicit environment variable names (in addition to the heuristically
|
|
51
|
+
* derived ones) that the assertion may read via `{{$.env.*}}` interpolations.
|
|
52
|
+
*/
|
|
53
|
+
envVars?: string[];
|
|
54
|
+
/**
|
|
55
|
+
* Explicitly supply environment variable values that amend (or override)
|
|
56
|
+
* the environment observed by this `page.ai.assert` call. Keys are merged
|
|
57
|
+
* with any names derived from {@link AssertOptions.envVars} and from
|
|
58
|
+
* `{{$.env.*}}` interpolations in the assertion text.
|
|
59
|
+
*
|
|
60
|
+
* - A `string` value sets or overrides the variable for this invocation.
|
|
61
|
+
* - An `undefined` value *removes* the variable, even if it would
|
|
62
|
+
* otherwise be resolved from persistence.
|
|
63
|
+
*
|
|
64
|
+
* Only the **names** (keys) influence cache lookup; changing a value
|
|
65
|
+
* replays the cached steps with the new value via `{{$.env.*}}` placeholder
|
|
66
|
+
* substitution rather than busting the cache.
|
|
67
|
+
*/
|
|
68
|
+
envVals?: Record<string, string | undefined>;
|
|
45
69
|
};
|
|
46
70
|
type PageAiAct = {
|
|
47
71
|
<Schema extends z.ZodObject>(instruction: string, options?: PageAiActWithSchemaOptions<Schema>): Promise<z.infer<Schema>>;
|
|
@@ -10,6 +10,7 @@ const GptApiKeysNotSetupException_1 = require("../../exceptions/GptApiKeysNotSet
|
|
|
10
10
|
const TestNotFoundException_1 = require("../../exceptions/TestNotFoundException");
|
|
11
11
|
const ToolCallFailedException_1 = require("../../exceptions/ToolCallFailedException");
|
|
12
12
|
const ToolRequiresGptException_1 = require("../../exceptions/ToolRequiresGptException");
|
|
13
|
+
const DonobuFlowsManager_1 = require("../../managers/DonobuFlowsManager");
|
|
13
14
|
const InteractionVisualizer_1 = require("../../managers/InteractionVisualizer");
|
|
14
15
|
const ToolManager_1 = require("../../managers/ToolManager");
|
|
15
16
|
const WebTargetInspector_1 = require("../../managers/WebTargetInspector");
|
|
@@ -220,6 +221,33 @@ Valid options:
|
|
|
220
221
|
const clearCache = sharedState.runtimeDirectives?.clearPageAiCache ?? false;
|
|
221
222
|
const retries = options?.retries ?? 0;
|
|
222
223
|
const retryDelaySeconds = options?.retryDelaySeconds ?? 3;
|
|
224
|
+
// Distill env var names from `{{$.env.*}}` interpolations in the
|
|
225
|
+
// assertion plus any explicitly provided names/overrides. Cached
|
|
226
|
+
// Playwright steps may carry the same `{{$.env.X}}` placeholders in
|
|
227
|
+
// their `value`/`attributeValue` fields, so we resolve env data at
|
|
228
|
+
// replay time and let the executor interpolate before applying.
|
|
229
|
+
const envVarNames = (0, DonobuFlowsManager_1.distillAllowedEnvVariableNames)(assertion, [
|
|
230
|
+
...(options?.envVars ?? []),
|
|
231
|
+
...Object.keys(options?.envVals ?? {}),
|
|
232
|
+
]);
|
|
233
|
+
const hasEnvRefs = envVarNames.length > 0;
|
|
234
|
+
const resolveEnvData = async () => {
|
|
235
|
+
if (!hasEnvRefs) {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
const envData = await sharedState.donobuStack.envDataManager.getByNames(envVarNames);
|
|
239
|
+
if (options?.envVals) {
|
|
240
|
+
for (const [k, v] of Object.entries(options.envVals)) {
|
|
241
|
+
if (v === undefined) {
|
|
242
|
+
delete envData[k];
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
envData[k] = v;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return envData;
|
|
250
|
+
};
|
|
223
251
|
// --- Cache lookup (when enabled and not clearing) ---
|
|
224
252
|
if (useCache && !clearCache) {
|
|
225
253
|
const cache = getOrInitPageAiCache();
|
|
@@ -227,6 +255,7 @@ Valid options:
|
|
|
227
255
|
const cached = await cache.getAssert({ pageUrl, assertion });
|
|
228
256
|
if (cached) {
|
|
229
257
|
Logger_1.appLogger.debug(`Assert cache HIT for: "${assertion}" - running cached Playwright assertion`);
|
|
258
|
+
const envData = await resolveEnvData();
|
|
230
259
|
let lastError = null;
|
|
231
260
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
232
261
|
if (attempt > 0) {
|
|
@@ -234,7 +263,7 @@ Valid options:
|
|
|
234
263
|
await page.waitForTimeout(retryDelaySeconds * 1000);
|
|
235
264
|
}
|
|
236
265
|
try {
|
|
237
|
-
await cached.run({ page });
|
|
266
|
+
await cached.run({ page, envData });
|
|
238
267
|
return; // Assertion passed
|
|
239
268
|
}
|
|
240
269
|
catch (error) {
|
|
@@ -263,12 +292,28 @@ Valid options:
|
|
|
263
292
|
await cache.deleteAssert({ pageUrl, assertion });
|
|
264
293
|
Logger_1.appLogger.debug(`Assert cache invalidated for: "${assertion}"`);
|
|
265
294
|
}
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
295
|
+
// Make env vars available to runTool's envData for `{{$.env.*}}`
|
|
296
|
+
// interpolation inside `assertionToTestFor` and so AssertTool can
|
|
297
|
+
// instruct the AI to emit placeholders in cached step values. Mirrors
|
|
298
|
+
// PageAi.ai for `act`: metadata.envVars is set (overwriting), envVals
|
|
299
|
+
// is restored.
|
|
300
|
+
if (hasEnvRefs) {
|
|
301
|
+
sharedState.donobuFlowMetadata.envVars = envVarNames;
|
|
302
|
+
}
|
|
303
|
+
const previousEnvVals = sharedState.envVals;
|
|
304
|
+
sharedState.envVals = options?.envVals;
|
|
305
|
+
let result;
|
|
306
|
+
try {
|
|
307
|
+
// --- Cache miss or cache disabled: run AI assertion ---
|
|
308
|
+
result = await runTool(page, AssertTool_1.AssertTool.NAME, {
|
|
309
|
+
assertionToTestFor: assertion,
|
|
310
|
+
retries: options?.retries,
|
|
311
|
+
retryWaitSeconds: options?.retryDelaySeconds,
|
|
312
|
+
}, options?.gptClient);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
sharedState.envVals = previousEnvVals;
|
|
316
|
+
}
|
|
272
317
|
if (!result.outcome.isSuccessful) {
|
|
273
318
|
throw new ToolCallFailedException_1.ToolCallFailedException(AssertTool_1.AssertTool.NAME, result.outcome);
|
|
274
319
|
}
|
|
@@ -363,33 +408,86 @@ Use this information to return an appropriate JSON object.`,
|
|
|
363
408
|
const useCache = options?.cache !== false;
|
|
364
409
|
const clearCache = sharedState.runtimeDirectives?.clearPageAiCache ?? false;
|
|
365
410
|
const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
411
|
+
// Distill env var names referenced by the description plus any
|
|
412
|
+
// explicitly provided names/overrides. Resolve env data locally — locate
|
|
413
|
+
// does not flow through `runTool`, so we don't mutate sharedState here.
|
|
414
|
+
const envVarNames = (0, DonobuFlowsManager_1.distillAllowedEnvVariableNames)(description, [
|
|
415
|
+
...(options?.envVars ?? []),
|
|
416
|
+
...Object.keys(options?.envVals ?? {}),
|
|
417
|
+
]);
|
|
418
|
+
const hasEnvRefs = envVarNames.length > 0;
|
|
419
|
+
const resolveEnvData = async () => {
|
|
420
|
+
if (!hasEnvRefs) {
|
|
421
|
+
return undefined;
|
|
373
422
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
423
|
+
const envData = await sharedState.donobuStack.envDataManager.getByNames(envVarNames);
|
|
424
|
+
if (options?.envVals) {
|
|
425
|
+
for (const [k, v] of Object.entries(options.envVals)) {
|
|
426
|
+
if (v === undefined) {
|
|
427
|
+
delete envData[k];
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
envData[k] = v;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return envData;
|
|
435
|
+
};
|
|
436
|
+
// The user-supplied `timeout` (default 30s) is the budget for the
|
|
437
|
+
// ENTIRE locate operation — cache-hit hydration wait + AI fallback.
|
|
438
|
+
// We start the abort timer here so the cache path's `waitFor` and the
|
|
439
|
+
// AI path share one bounded clock.
|
|
386
440
|
const timeoutMillis = options?.timeout ?? 30_000;
|
|
441
|
+
const startedAt = Date.now();
|
|
387
442
|
const abortController = new AbortController();
|
|
388
443
|
const timeoutId = setTimeout(() => {
|
|
389
444
|
abortController.abort(`Locate operation timed out after ${timeoutMillis} milliseconds`);
|
|
390
445
|
}, timeoutMillis);
|
|
391
446
|
try {
|
|
392
|
-
|
|
447
|
+
// --- Cache lookup (when enabled and not clearing) ---
|
|
448
|
+
if (useCache && !clearCache) {
|
|
449
|
+
const cache = getOrInitPageAiCache();
|
|
450
|
+
const cached = await cache.getLocate({ pageUrl, description });
|
|
451
|
+
if (cached) {
|
|
452
|
+
const envData = await resolveEnvData();
|
|
453
|
+
const candidate = cached.run({ page, envData });
|
|
454
|
+
// Cache replay can outrun page hydration — the no-cache path
|
|
455
|
+
// gets an implicit hydration window from the AI round-trip
|
|
456
|
+
// latency, but a cache hit fires immediately and may see a
|
|
457
|
+
// partially-mounted DOM. Wait (within the operation's overall
|
|
458
|
+
// budget) for the locator to attach before validating.
|
|
459
|
+
const remaining = Math.max(timeoutMillis - (Date.now() - startedAt), 100);
|
|
460
|
+
try {
|
|
461
|
+
await candidate.first().waitFor({
|
|
462
|
+
state: 'attached',
|
|
463
|
+
timeout: remaining,
|
|
464
|
+
});
|
|
465
|
+
Logger_1.appLogger.debug(`Locate cache HIT for: "${description}" — rebuilt locator from cache`);
|
|
466
|
+
return candidate;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
// Locator did not attach within the patience window. Either
|
|
470
|
+
// the page has drifted or the cache is genuinely stale.
|
|
471
|
+
// Invalidate and fall through to the AI path; the AI call
|
|
472
|
+
// gets whatever budget remains on the abort timer.
|
|
473
|
+
Logger_1.appLogger.debug(`Locate cache STALE for "${description}" (no match within ${remaining}ms) — re-running AI`);
|
|
474
|
+
await cache.deleteLocate({ pageUrl, description });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// --- Cache invalidation (when clearing) ---
|
|
479
|
+
if (useCache && clearCache) {
|
|
480
|
+
const cache = getOrInitPageAiCache();
|
|
481
|
+
await cache.deleteLocate({ pageUrl, description });
|
|
482
|
+
Logger_1.appLogger.debug(`Locate cache invalidated for: "${description}"`);
|
|
483
|
+
}
|
|
484
|
+
// --- Cache miss / cache disabled / stale-cache fallthrough: run AI ---
|
|
485
|
+
const gptClient = getGptClient(page, options?.gptClient);
|
|
486
|
+
if (!gptClient) {
|
|
487
|
+
throw new ToolRequiresGptException_1.ToolRequiresGptException('locate');
|
|
488
|
+
}
|
|
489
|
+
const envData = await resolveEnvData();
|
|
490
|
+
const { locator, result } = await (0, locateElement_1.locateElement)(page, description, gptClient, { signal: abortController.signal, envData });
|
|
393
491
|
// --- Cache the result for future runs ---
|
|
394
492
|
if (useCache) {
|
|
395
493
|
try {
|
|
@@ -87,6 +87,9 @@ class ToolManager {
|
|
|
87
87
|
startedAt: startedAt,
|
|
88
88
|
completedAt: null,
|
|
89
89
|
});
|
|
90
|
+
// Expose the un-interpolated parameters so tools that need to
|
|
91
|
+
// preserve `{{...}}` references (e.g. AssertTool) can read them.
|
|
92
|
+
context.rawParameters = toolParameters;
|
|
90
93
|
// Use the interpolated parameters when calling the tool.
|
|
91
94
|
toolCallResult = isFromGpt
|
|
92
95
|
? await tool.callFromGpt(context, tool.inputSchemaForGpt.parse(interpolatedParameters))
|
|
@@ -22,5 +22,16 @@ export type ToolCallContext = {
|
|
|
22
22
|
readonly invokedToolCalls: ToolCall[];
|
|
23
23
|
readonly metadata: FlowMetadata;
|
|
24
24
|
readonly toolCallId: string;
|
|
25
|
+
/**
|
|
26
|
+
* Original (un-interpolated) parameters supplied to the current tool call.
|
|
27
|
+
*
|
|
28
|
+
* `ToolManager.invokeTool` interpolates `{{...}}` expressions in the tool's
|
|
29
|
+
* parameters before invoking the tool, but a tool may need the raw text to
|
|
30
|
+
* preserve env var references in its output (e.g. `AssertTool` emits
|
|
31
|
+
* Playwright steps that retain `{{$.env.X}}` so cached replays stay correct
|
|
32
|
+
* across env value changes). Set by `ToolManager` immediately before the
|
|
33
|
+
* tool runs; absent for direct/legacy invocation paths.
|
|
34
|
+
*/
|
|
35
|
+
rawParameters?: Record<string, any>;
|
|
25
36
|
};
|
|
26
37
|
//# sourceMappingURL=ToolCallContext.d.ts.map
|
package/dist/tools/AssertTool.js
CHANGED
|
@@ -124,6 +124,36 @@ It will use a screenshot of the current viewport of the webpage, the webpage's t
|
|
|
124
124
|
Logger_1.appLogger.warn(msg);
|
|
125
125
|
return msg;
|
|
126
126
|
});
|
|
127
|
+
const rawAssertion = typeof context.rawParameters?.assertionToTestFor === 'string'
|
|
128
|
+
? context.rawParameters.assertionToTestFor
|
|
129
|
+
: parameters.assertionToTestFor;
|
|
130
|
+
const envEntries = Object.entries(context.envData ?? {});
|
|
131
|
+
// Only treat env vars as "in play" when the raw assertion actually
|
|
132
|
+
// references one — keeps the prompt small for the common case.
|
|
133
|
+
const referencedEnvEntries = envEntries.filter(([name]) => rawAssertion.includes(`{{$.env.${name}}}`));
|
|
134
|
+
const hasEnvRefs = referencedEnvEntries.length > 0;
|
|
135
|
+
const envBlock = hasEnvRefs
|
|
136
|
+
? `
|
|
137
|
+
|
|
138
|
+
The user's original assertion contains environment variable references using the
|
|
139
|
+
syntax \`{{$.env.NAME}}\`. To keep cached Playwright steps valid across runs with
|
|
140
|
+
different env values, you MUST emit those same placeholders in any
|
|
141
|
+
playwrightAssertionStep \`value\`/\`attributeValue\` field whose contents come from
|
|
142
|
+
an env var. Do NOT bake the literal current value into the step.
|
|
143
|
+
|
|
144
|
+
Original (uninterpolated) assertion: ${rawAssertion}
|
|
145
|
+
|
|
146
|
+
Current env mapping (use these to identify which substrings on the page came
|
|
147
|
+
from which env var, then emit the placeholder rather than the literal):
|
|
148
|
+
${referencedEnvEntries.map(([name, value]) => ` - {{$.env.${name}}} = ${JSON.stringify(value)}`).join('\n')}
|
|
149
|
+
|
|
150
|
+
Examples:
|
|
151
|
+
- Raw assertion "Welcome banner says hello {{$.env.USERNAME}}", USERNAME="alice", page shows "Welcome alice" →
|
|
152
|
+
[{ locator: "text", role: null, value: "{{$.env.USERNAME}}", valueIsRegex: false, assertion: "toBeVisible", attributeValue: null }]
|
|
153
|
+
- Raw assertion "The username field shows {{$.env.USERNAME}}", USERNAME="alice", page input value is "alice" →
|
|
154
|
+
[{ locator: "label", role: null, value: "Username", valueIsRegex: false, assertion: "toHaveValue", attributeValue: "{{$.env.USERNAME}}" }]
|
|
155
|
+
- For literal page text unrelated to env vars, keep the literal value as usual.`
|
|
156
|
+
: '';
|
|
127
157
|
const promptMessages = [
|
|
128
158
|
{
|
|
129
159
|
type: 'system',
|
|
@@ -142,7 +172,7 @@ CRITICAL RULES for generating structured steps — follow these precisely:
|
|
|
142
172
|
- Text input / textarea content: use 'toHaveValue' with locator='label' and set attributeValue to the expected text. Do NOT use 'toBeVisible' on the textbox.
|
|
143
173
|
- Selected tabs, pills, or items with aria-selected: use 'toHaveAttribute' with value='aria-selected' and attributeValue='true', NOT 'toBeVisible' on the text.
|
|
144
174
|
- Text content within an element: use 'toContainText' with attributeValue set to the substring, NOT 'toBeVisible'.
|
|
145
|
-
- Only use 'toBeVisible' when the assertion is genuinely about whether something is visible — not as a fallback for state or value checks
|
|
175
|
+
- Only use 'toBeVisible' when the assertion is genuinely about whether something is visible — not as a fallback for state or value checks.${envBlock}`,
|
|
146
176
|
},
|
|
147
177
|
{
|
|
148
178
|
type: 'user',
|
|
@@ -184,7 +214,7 @@ careful positioning lost, etc. A screenshot of the webpage has also been provide
|
|
|
184
214
|
verifiedSteps.length > 0) {
|
|
185
215
|
try {
|
|
186
216
|
const executor = (0, assertCache_1.buildAssertExecutor)(verifiedSteps);
|
|
187
|
-
await executor({ page: page });
|
|
217
|
+
await executor({ page: page, envData: context.envData });
|
|
188
218
|
}
|
|
189
219
|
catch (error) {
|
|
190
220
|
Logger_1.appLogger.debug(`Structured assertion steps failed verification for: "${parameters.assertionToTestFor}" — discarding steps. Error: ${error.message}`);
|