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.
@@ -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: "${description}"\n\nCandidates:\n${snippetText}\n\nReturn the index of the best match.`,
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
- /** Timeout in milliseconds for the entire locate operation (default: 30 000). */
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
- /** Whether to use the on-disk cache. Defaults to true. */
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
- // --- Cache miss or cache disabled: run AI assertion ---
267
- const result = await runTool(page, AssertTool_1.AssertTool.NAME, {
268
- assertionToTestFor: assertion,
269
- retries: options?.retries,
270
- retryWaitSeconds: options?.retryDelaySeconds,
271
- }, options?.gptClient);
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
- // --- Cache lookup (when enabled and not clearing) ---
367
- if (useCache && !clearCache) {
368
- const cache = getOrInitPageAiCache();
369
- const cached = await cache.getLocate({ pageUrl, description });
370
- if (cached) {
371
- Logger_1.appLogger.debug(`Locate cache HIT for: "${description}" — rebuilding locator from cache`);
372
- return cached.run({ page });
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
- // --- Cache invalidation (when clearing) ---
376
- if (useCache && clearCache) {
377
- const cache = getOrInitPageAiCache();
378
- await cache.deleteLocate({ pageUrl, description });
379
- Logger_1.appLogger.debug(`Locate cache invalidated for: "${description}"`);
380
- }
381
- // --- Cache miss or cache disabled: run AI locate ---
382
- const gptClient = getGptClient(page, options?.gptClient);
383
- if (!gptClient) {
384
- throw new ToolRequiresGptException_1.ToolRequiresGptException('locate');
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
- const { locator, result } = await (0, locateElement_1.locateElement)(page, description, gptClient, { signal: abortController.signal });
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
@@ -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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donobu",
3
- "version": "5.33.0",
3
+ "version": "5.35.0",
4
4
  "description": "Create browser automations with an LLM agent and replay them as Playwright scripts.",
5
5
  "main": "dist/main.js",
6
6
  "module": "dist/esm/main.js",