@cairnvibe/sdk 0.2.13 → 0.3.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.
@@ -40,6 +40,94 @@ export function findElement(target: string, liveElements?: Map<string, HTMLEleme
40
40
  return null;
41
41
  }
42
42
 
43
+ /**
44
+ * Phase 3 step 4 (see DEVELOPMENT.md/the plan file) — CODA's own point:
45
+ * the Executor gets real local retry latitude for a genuinely MECHANICAL
46
+ * miss (a re-render replaced the DOM node the frozen liveElements snapshot
47
+ * pointed at; an animation/async render hadn't settled yet) before a
48
+ * failure escalates all the way to the Critic/a replan. Deliberately NOT
49
+ * a second LLM call — the Executor stays opinion-free, exactly re-running
50
+ * the SAME real lookup (which, past the liveElements-map check, already
51
+ * queries the LIVE DOM directly — a stale snapshot doesn't matter to that
52
+ * part) after a short real wait. A target that's genuinely not on the
53
+ * page still fails after `attempts`, surfacing as a real miss — this
54
+ * never silently invents success.
55
+ */
56
+ export async function findElementWithRetry(
57
+ target: string,
58
+ liveElements?: Map<string, HTMLElement>,
59
+ attempts = 2,
60
+ delayMs = 300,
61
+ ): Promise<HTMLElement | null> {
62
+ for (let i = 0; i < attempts; i++) {
63
+ const el = findElement(target, liveElements);
64
+ if (el) return el;
65
+ if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Real, live-found bug this closes: a `fill`/`click` step reported itself
72
+ * "done" the instant its DOM event was dispatched — but the app's own
73
+ * reaction to that event (a filtered search-results grid re-rendering, a
74
+ * cart count updating) can be an unbounded-latency async round trip (a
75
+ * Next.js App Router `router.push` re-fetching a server component, for
76
+ * example — not a fixed debounce with a known delay to just sleep past).
77
+ * A `read` step immediately after saw STALE content and the agent
78
+ * confidently reported findings that didn't match what the page actually,
79
+ * eventually, showed — confirmed live: typing "book" into a search box,
80
+ * then reading the still-unfiltered product grid a moment later, and
81
+ * reporting a match ("Novel: The Long Way") the REAL, since-filtered page
82
+ * went on to show zero results for.
83
+ *
84
+ * Waits for real DOM mutations instead of guessing a sleep duration: if
85
+ * nothing starts mutating within `initialWaitMs`, resolves immediately
86
+ * (the action had no async effect at all — no reason to add latency to
87
+ * the common case); once mutations start, waits for `quietMs` of no
88
+ * further mutations before considering the page settled; a hard
89
+ * `timeoutMs` ceiling means a page that never stops mutating (an
90
+ * animation, a polling widget) can't stall the agent loop forever.
91
+ */
92
+ export function waitForDomSettle(initialWaitMs = 100, quietMs = 200, timeoutMs = 1500): Promise<void> {
93
+ return new Promise((resolve) => {
94
+ // Real gap this closes: some callers stub a partial `document` (real
95
+ // tests in this repo do exactly that for other reasons — a fake
96
+ // WebMCP-tool document, for instance) without the rest of the DOM API
97
+ // surface — checking `document` alone isn't enough to guarantee
98
+ // MutationObserver (or document.body) actually exist too.
99
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined" || !document.body) {
100
+ resolve();
101
+ return;
102
+ }
103
+ let settled = false;
104
+ let sawMutation = false;
105
+ let quietTimer: ReturnType<typeof setTimeout> | null = null;
106
+
107
+ const finish = () => {
108
+ if (settled) return;
109
+ settled = true;
110
+ observer.disconnect();
111
+ if (quietTimer) clearTimeout(quietTimer);
112
+ clearTimeout(hardCap);
113
+ resolve();
114
+ };
115
+
116
+ const observer = new MutationObserver(() => {
117
+ sawMutation = true;
118
+ if (quietTimer) clearTimeout(quietTimer);
119
+ quietTimer = setTimeout(finish, quietMs);
120
+ });
121
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
122
+
123
+ const hardCap = setTimeout(finish, timeoutMs);
124
+
125
+ setTimeout(() => {
126
+ if (!sawMutation) finish(); // the action had no async effect — nothing to wait for
127
+ }, initialWaitMs);
128
+ });
129
+ }
130
+
43
131
  export function highlightElement(el: HTMLElement, glowMs = 4000): void {
44
132
  el.scrollIntoView({ behavior: "smooth", block: "center" });
45
133
  el.classList.add("cairn-glow");
@@ -87,6 +175,88 @@ export function readElement(el: HTMLElement): string {
87
175
  return trimmed.length > 500 ? `${trimmed.slice(0, 499)}…` : trimmed || "(empty)";
88
176
  }
89
177
 
178
+ /**
179
+ * Chooses a real `<option>` by its visible text — never a raw internal
180
+ * `value` the model could never actually see. Native `<select>` gets the
181
+ * direct path (set `.value` to the matching option's own value, then fire
182
+ * the same input/change pair fillElement uses so React notices). A custom
183
+ * listbox/combobox (role="listbox"/"option" — Radix, Headless UI, etc.)
184
+ * has no real `<option>` to set, so the fallback clicks the matching
185
+ * option-shaped descendant instead, the same "do the real user gesture"
186
+ * principle the do/click cases already follow.
187
+ */
188
+ export function selectOption(el: HTMLElement, visibleText: string): boolean {
189
+ if (el.tagName === "SELECT") {
190
+ const select = el as HTMLSelectElement;
191
+ const match = Array.from(select.options).find((o) => normalize(o.textContent ?? "") === normalize(visibleText)) ?? Array.from(select.options).find((o) => normalize(o.textContent ?? "").includes(normalize(visibleText)));
192
+ if (!match) return false;
193
+ select.value = match.value;
194
+ select.dispatchEvent(new Event("input", { bubbles: true }));
195
+ select.dispatchEvent(new Event("change", { bubbles: true }));
196
+ return true;
197
+ }
198
+
199
+ const candidates = el.querySelectorAll<HTMLElement>('[role="option"], option, li, [role="menuitem"]');
200
+ const match = Array.from(candidates).find((c) => normalize(c.textContent ?? "") === normalize(visibleText)) ?? Array.from(candidates).find((c) => normalize(c.textContent ?? "").includes(normalize(visibleText)));
201
+ if (!match) return false;
202
+ match.click();
203
+ return true;
204
+ }
205
+
206
+ /**
207
+ * A real multi-point pointer-event sequence — pointerdown on `from`'s
208
+ * center, several pointermove steps toward `to`'s center, pointerup on
209
+ * `to` — the same technique a real mouse drag produces, for canvas/kanban/
210
+ * sortable-list libraries (react-dnd, dnd-kit, n8n's own node canvas) that
211
+ * listen for pointer events rather than a single synthetic "drop". Mouse
212
+ * events are fired alongside (same coordinates) for the older libraries
213
+ * that still only listen for those. jsdom's getBoundingClientRect returns
214
+ * all-zero rects with no real layout engine — fine here, since what matters
215
+ * for a test is that the sequence fires with consistent coordinates, not
216
+ * that they reflect real pixels.
217
+ */
218
+ export function dragElement(from: HTMLElement, to: HTMLElement, steps = 5): void {
219
+ const fromRect = from.getBoundingClientRect();
220
+ const toRect = to.getBoundingClientRect();
221
+ const fromX = fromRect.left + fromRect.width / 2;
222
+ const fromY = fromRect.top + fromRect.height / 2;
223
+ const toX = toRect.left + toRect.width / 2;
224
+ const toY = toRect.top + toRect.height / 2;
225
+
226
+ const fire = (target: HTMLElement, type: string, x: number, y: number) => {
227
+ const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, view: typeof window !== "undefined" ? window : undefined };
228
+ if (typeof PointerEvent !== "undefined") target.dispatchEvent(new PointerEvent(type.replace("mouse", "pointer"), opts));
229
+ target.dispatchEvent(new MouseEvent(type, opts));
230
+ };
231
+
232
+ fire(from, "mousedown", fromX, fromY);
233
+ for (let i = 1; i <= steps; i++) {
234
+ const x = fromX + ((toX - fromX) * i) / steps;
235
+ const y = fromY + ((toY - fromY) * i) / steps;
236
+ fire(i === steps ? to : from, "mousemove", x, y);
237
+ }
238
+ fire(to, "mouseup", toX, toY);
239
+ }
240
+
241
+ const KEYS_WITH_PRINTABLE_CHAR = new Set(["Enter", "Tab"]);
242
+
243
+ /**
244
+ * Presses one real key on a target element — focuses it first (a real
245
+ * keypress always lands on whatever's focused; a component that reacts to
246
+ * Escape/Enter/arrows almost always keys off document-level or its own
247
+ * focus-scoped listener, so focus has to be real before the event fires).
248
+ * Fires keydown/keyup (and keypress only for the handful of keys that
249
+ * still expect one — Enter/Tab — matching a real browser's own behavior,
250
+ * which no longer fires keypress for pure navigation keys like arrows).
251
+ */
252
+ export function pressKey(el: HTMLElement, key: string): void {
253
+ if (typeof el.focus === "function") el.focus();
254
+ const opts = { bubbles: true, cancelable: true, key };
255
+ el.dispatchEvent(new KeyboardEvent("keydown", opts));
256
+ if (KEYS_WITH_PRINTABLE_CHAR.has(key)) el.dispatchEvent(new KeyboardEvent("keypress", opts));
257
+ el.dispatchEvent(new KeyboardEvent("keyup", opts));
258
+ }
259
+
90
260
  export interface MissContext {
91
261
  attempted: string;
92
262
  route: string;