Skip to content

fix(ego-browser): eight runtime defects, each with a regression test - #64

Open
zhuran24 wants to merge 8 commits into
opencue:mainfrom
zhuran24:fix/runtime-defects
Open

zhuran24 wants to merge 8 commits into
opencue:mainfrom
zhuran24:fix/runtime-defects

Conversation

@zhuran24

Copy link
Copy Markdown

Summary

Eight independent defects in the ego-browser runtime, one commit each. Every commit adds tests that fail against main and pass with its fix. The commits touch disjoint files, so each one can be reviewed or reverted on its own.

Related issue

None: issues are disabled on this repository, so each defect is described below.

Changes

1. ~/ paths lost the home directory (src/env.ts)

resolvePath("~/agent-skills") called resolve(home, "/agent-skills"), which returns /agent-skills. An EGO_BROWSER_AGENT_WORKSPACE written with ~/ therefore pointed at the filesystem root, and ~other was rewritten to $HOME/other. Now only ~ and ~/… (~\… on Windows) expand, by joining the rest onto the home directory, so ~//skills works too; any other path resolves as written. Prettier also rewraps one statement in loadEnvFile, because the quality gate checks whole changed files.

On main, resolvePath("~/skills/ego-browser") returns /skills/ego-browser.

2. waitForURL missed a match on the second use of a /g or /y pattern (src/driver/waits.ts)

RegExp.prototype.test() leaves lastIndex of a global or sticky pattern at the end of the last match. When the same pattern was passed to another waitForURL(), its first check started part-way into the URL and failed on a page that matched. That failure reset lastIndex, so the wait matched only on the next poll, 25 ms later, and returned false if the timeout ran out first. lastIndex is now reset before each test, as networkMatches() in the same file already does.

On main, with a 20 ms timeout, the second waitForURL(/target/g) on the same URL returns false.

3. Concurrent first @ref actions failed with "Unknown ref" (src/ref-state.ts)

When the ref map is empty, the first @ref action takes a snapshot to rebuild it. The guard was a boolean, so a second action that arrived during that refresh returned at once and resolved against the still-empty map. The in-flight refresh is now shared. Every caller waits for it and gets its error if it fails, and the next action after a failure retries.

On main, the second of two concurrent elementCenter("@7") calls rejects with Unknown ref: 7.

4. Async callbacks in locator.evaluate() and evaluateAll() on refs returned {} (src/driver/locator.ts, src/driver/element-ops.ts)

Both paths called Runtime.callFunctionOn with awaitPromise: false. An async pageFunction therefore returned its Promise serialized as {}, and a rejection inside it was lost. evaluateAll() over CSS and role locators already awaited. resolveAndCall() gains an awaitPromise option, set only by these two reads; every other element action keeps its current options.

On main, an async callback that resolves to "Ready" yields {}, and one that throws does not reject.

5. Chained nth(), first() and last() failed with "Invalid selector" (src/element-resolver.ts)

parseLocator() removed one internal:nth= prefix and then one internal:last; prefix. page.locator(".item").nth(1).first() thus left internal:nth=1;loc=css:.item to be parsed as CSS; nth(1).last() and last().last() failed the same way. It now removes every selection prefix. Each selection leaves at most one element, so the innermost one picks the element, and a role locator keeps its AX path. An outer nth(n) with n > 0 can only match nothing; it falls back to the full query, which reports a transient miss.

On main, internal:nth=0;internal:nth=1;loc=css:.item fails with Invalid selector: internal:nth=1;loc=css:.item.

6. waitForEvent('download') could end on another download, and a caught failure could still end the process (src/driver/downloads.ts)

  • The completion waiter accepted any GUID until the awaiting code had resumed and recorded the begin event. A completion or cancel of a different download could therefore end the wait: the facade came back before its own file was written, or the wrong download was reported as canceled.

  • The begin and completion waiters each had their own timer. When the wait failed (a timeout, or ensureSession() or setDownloadBehavior() rejecting), the other waiter stayed registered and rejected later with no handler. Under Node's default unhandled-rejection mode that ends the process, even when the script caught the error:

    try {
      await page.waitForEvent("download", { timeout: 50 });
    } catch (error) {
      console.log("caught:", error.message); // printed
    }
    await new Promise((resolve) => setTimeout(resolve, 200));
    console.log("still running"); // never printed on main: the process exits 1 with "Error: page.waitForEvent timed out"

The GUID is now recorded synchronously in the begin-event callback, and both subscriptions and the single timer are removed however the wait ends.

7. fill() typed into the focused element when a page-side step threw (src/driver/keyboard.ts)

cdp() reports a function that throws in the page as a result carrying exceptionDetails, not as a rejection, and fill() never checked its three Runtime.callFunctionOn calls. On a target that is neither a form field nor contenteditable, the clear step's fill target is not editable was dropped, and Input.insertText sent the value to whatever element had focus. Each step is now checked the way resolveAndCall() already does, so fill() rejects with the page error; the handle is still released. With clearFirst: false the clear step, and with it that check, does not run; that path is unchanged.

On main, fill() resolves even when the focus, clear or event step throws.

8. Screencast start and stop raced (src/driver/screencast.ts)

startScreencast() claimed the recording slot only after its awaits. Two overlapping starts both succeeded and the first recording was never stopped; a stopScreencast() during startup found nothing to stop, and the recording then ran on. stopScreencast() released the slot before finalizing, so a second stop() resolved before the WebM was written and a new start() could begin in the meantime.

The slot is now reserved before the first await and held until finalization ends, and concurrent stops share one promise. A startup failure after the encoder was created now stops the encoder too.

On main, the second of two overlapping starts is fulfilled instead of rejected, and an early stop() resolves before startup finishes.

Verification

Run from package/ego-browser on Node 22 or later.

Check main This branch
npm test (build, typecheck, tests) 403 passed 427 passed
The 24 new tests against main's source 23 fail, 1 passes¹ 24 pass
npm test at each of the 8 commits n/a all pass
npm run validate:site-skills passes passes
prettier --check on the changed files n/a clean
npm test in package/ego-linux on this build, as the test-linux job runs it 276 passed 276 passed

¹ startScreencast releases its reservation after session startup fails already passes on main; it covers the new reservation path.

Impact

Public helper behavior changes in three places:

  • fill() rejects when one of its page-side steps throws, for example on a target that is not editable, instead of typing into another element.
  • locator.evaluate() and evaluateAll() on an @ref resolve to an async callback's value.
  • A path starting with ~ but not ~/ (for example ~other) is no longer rewritten to $HOME/other.

No agent-facing documentation, site learning, installation flow or CI changes.

🤖 Generated with Claude Code

zhuran24 and others added 8 commits October 21, 2026 03:10
resolvePath() passed path.slice(1) to resolve(), so "~/agent-skills"
became resolve(home, "/agent-skills"), which is "/agent-skills": an
EGO_BROWSER_AGENT_WORKSPACE written with ~/ pointed at the filesystem
root. "~other" was also rewritten to "$HOME/other". Only "~" and "~/..."
("~\..." on Windows) now expand; anything else resolves as written.

Prettier also rewraps one statement in loadEnvFile; the quality gate
checks every changed file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RegExp.prototype.test() on a /g or /y pattern leaves lastIndex at the end
of the last match. Passing the same pattern to another waitForURL() made
its first check start part-way into the URL and fail on a page that
matched; the failure reset lastIndex, so the wait only matched on the
next poll, 25 ms later, and returned false when the timeout was shorter
than that. Reset lastIndex before each test, as networkMatches() in the
same file already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…resh

ensureRefMapForRef() guarded the automatic re-snapshot with a boolean. A
second @ref action that arrived while the first refresh was in flight
returned at once and resolved against the still-empty map, failing with
"Unknown ref". Share the in-flight refresh instead: every waiter gets its
result or its error, and the next action after a failure retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
locator.evaluate() and evaluateAll() on an @ref called
Runtime.callFunctionOn with awaitPromise: false, so an async pageFunction
returned its Promise serialized as {} and a rejection was lost.
evaluateAll() over CSS and role locators already awaited. resolveAndCall()
takes an awaitPromise option and these two reads set it; every other
element action keeps its current options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parseLocator() stripped one internal:nth= prefix and then one
internal:last prefix, so locator(".item").nth(1).first() left
"internal:nth=1;loc=css:.item" to be parsed as CSS and failed with
"Invalid selector"; nth(1).last() and last().last() failed the same way.

Peel every selection prefix. Each selection leaves at most one element,
so the innermost one picks the element and a role locator keeps its AX
path. An outer nth(n) with n > 0 can only match nothing; it falls back to
the full query, which reports a transient miss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…egan

The completion waiter accepted any GUID until the awaiting code had
resumed and recorded the begin event. A completion or cancel of another
download could therefore end the wait: the facade came back before its
own file was written, or the wrong download was reported as canceled.

The begin and completion waiters each had their own timer. When one wait
failed (timeout, or ensureSession()/setDownloadBehavior() rejecting) the
other stayed registered and rejected later with no handler. Under Node's
default unhandled-rejection mode that ends the process, even when the
script caught the error from waitForEvent().

Record the GUID synchronously in the begin-event callback, and drop both
subscriptions and the single timer however the wait ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fill() ignored exceptionDetails from its focus, clear and input/change
calls; cdp() reports a function that throws in the page as a result, not
a rejection. On a target that is neither a form field nor contenteditable,
the clear step's "fill target is not editable" was dropped and
Input.insertText typed the value into whatever element had focus.

Check each call the way resolveAndCall() does. The handle is still
released by withHandle().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
startScreencast() set activeRecording only after its awaits. Two
overlapping starts both succeeded and the first recording was never
stopped; stopScreencast() during startup found nothing to stop and the
recording then ran on. stopScreencast() cleared the slot before
finalizing, so a second stop() resolved before the file was written and
a new start() could begin meanwhile.

Reserve the slot before the first await, keep it until finalization ends,
and share one stop promise among callers. A startup failure after the
encoder was created now stops the encoder too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant