Commit Graph
130 Commits
Author SHA1 Message Date
Karl Seguin db98e85ce3 v8: update for changes to v8__String__WriteUtf8
https://github.com/lightpanda-io/zig-v8-fork/pull/211
2026-09-25 11:52:42 +08:00
Adrià Arrufat 90487270ba findElement: the name filter is one union; a literal is one by grammar
A name is a substring or a regex, never both, so the filter says so
instead of carrying two optionals the caller has to null against each
other. Whether `/.../x` is a literal is now decided by JavaScript's flag
alphabet rather than "looks like letters", which stops `/usr/bin` from
being read as a pattern with flags. Comments that narrated callers or
the type name are gone; the literal parser gets its own test in place
of two MCP round-trips.
2026-09-16 09:38:42 +02:00
Adrià Arrufat 5710f1d684 findElement: accept the flags of a JavaScript regex literal
A model that writes `/spice/i` should not be told the name has a
stray `i` in it. `i` and `u` are already how names match, `s` and `m`
map to their PCRE2 options, and any other letter is a tool error that
names it. Text after the closing slash that is not a letter makes the
whole thing a plain substring again.
2026-09-16 09:29:18 +02:00
Adrià Arrufat 158de60d64 findElement: match accessible names by regex
`name` is a substring, which cannot say "starts with", "exactly this"
or "either of these". A name written as `/.../`, the spelling adblock
lists already use, is a JavaScript-syntax pattern compiled through the
App's PCRE2 context; an invalid one comes back as a tool error carrying
PCRE2's message and offset so the model can fix it rather than retry
blind.
2026-09-15 09:33:39 +02:00
Adrià Arrufat b141439c83 refactor(input): adopt dispatchCancelable in wheel/keypress/press, trim comments
Route the mouse-wheel, keypress and press paths through the new
EventManager.dispatchCancelable instead of open-coding the acquire-ref /
dispatch / read-defaultPrevented dance three more times. Use
getAttributeInterned for href/tabindex in the focusability helpers to match
their neighbors, and drop WHAT-restating comments.
2026-09-11 10:49:29 +02:00
Adrià Arrufat 0e04a2fe5d Merge remote-tracking branch 'origin/main' into fix/click-dispatch-full-mouse-sequence
# Conflicts:
#	src/browser/frame/user_input.zig
#	src/browser/webapi/Element.zig
2026-09-11 10:39:53 +02:00
Adrià Arrufat 032502db21 refactor(input): share dispatch-cancelable and native-focusable helpers
Extract EventManager.dispatchCancelable so the acquire-ref/dispatch/read-
defaultPrevented pattern lives in one place instead of four copies across
actions.zig, user_input.zig and WebDriver.zig. Share a single
isNativelyFocusable predicate between the mouse-focus rules and moveFocus,
which had the same tag switch verbatim, and fold the SVG-link check into
isSvgLink. Return early from Element.focus when the element is already
active, before the visibility walk, so a click no longer pays a CSS
ancestor walk to re-focus a native control. Collapse the repeated MCP
click-test blocks into a selector loop and give the runtime focus test
active()/expectActive() helpers.
2026-09-11 10:35:37 +02:00
Ramiro_quaiandClaude Opus 5 5e43683a6e fix(input): honor mousedown preventDefault and correct mouse-focus rules
Addresses review on the mousedown default action.

Blur when a mousedown lands outside any focusable element: focusForMouseDown
now blurs the document's active element when the ancestor walk finds nothing
mouse-focusable, so a plain click moves focus to the body. The MCP fixture had
this backwards and asserted the old behavior; it now records
document.activeElement === document.body.

Treat an unparsable tabindex as if the attribute were absent (HTML 6.6.3)
rather than as a terminal "not focusable" answer, so <button tabindex="abc">
keeps its native focusability. This mirrors HtmlElement.getTabIndex's
parse-failure fallthrough for the same attribute instead of introducing a
second parser with different semantics.

Check the tabindex attribute before the HTML-only guard. An explicit,
parseable tabindex is focusable on any element, and SVG links are focusable by
the same href they activate on. This was only a gap until the blur above made
it a regression: clicking such an element used to leave focus alone and would
now have dropped it to the body, where Chrome focuses the element.

Honor preventDefault() on mousedown. The default action ran unconditionally,
so the toolbar idiom -- preventDefault() on mousedown to keep focus in a
focused input -- did not work, and the new blur made that load-bearing. The
mouse dispatch helpers in actions.zig, user_input.zig and WebDriver.zig now
report whether the event was cancelled, and each call site gates
focusForMouseDown on it. Non-mousedown call sites discard the result.

Editing-host focus behavior is deliberately unchanged.

Fold mouseFocusTabIndex into isMouseFocusable; its ?i32 return was never read.
Drop the duplicate Runtime.zig test that drove the same actions.click path as
the tools.zig fixture, and expand the remaining one, renamed to reflect what it
now covers. Two of its assertions sample document.activeElement in a mouseup
listener rather than after the click, because click activation behavior focuses
those elements unconditionally and would mask what mousedown decided. Harden
the disabled-control test to record the full five-event sequence instead of
mousedown alone. Every assertion was checked to fail against the pre-fix code.

zig build test 1340/1340, zig build test -Dwpt_extensions 1340/1340,
zig fmt --check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 01:10:04 -06:00
Karl Seguin 2e6999f20b chore: make declarations private if they don't need to be public
This change is 99%  s/pub//   + a handful of dead code removal.
2026-09-10 14:42:09 +08:00
Ramiro_quai e2d878eee8 fix(input): mousedown focuses tabindex targets, not only contenteditable
focusEditingHostForMouseDown only walked contenteditable hosts, so a
div[tabindex=0] stayed unfocused after click. Replace it with
focusForMouseDown: still prefer the outermost editing host, then focus
the nearest mouse-focusable element (including tabindex=-1).

Call sites: actions.click, CDP triggerMousePress, WebDriver pointerDown.

Tests cover the MCP selector path, agent Page.click on the HTML fixture,
and a runtime-created child click (ancestor walk, non-focusable must not
steal focus, tabindex=-1 is mouse-focusable).
2026-09-07 23:31:09 -06:00
Karl Seguin 7c51dfff05 bidi: add skeleton for bidi
1. Abstract "Driver". Non-CDP things that referenced *CDP now reference a Driver
2. Move the NodeRegistry out of CDP. This created an artificial link between
   agent / mcp and CDP
3. Add BiDi driver with enough to navigate to a page
2026-08-31 22:44:59 +08:00
Adrià Arrufat cff32f6c04 agent: let the model see a REPL /screenshot; fold the tool-result adapters
The slash path's result has two consumers: the terminal, which can't
show an image, and the conversation, which can. Opt in when a model is
attached and forward the image through the same adapter the model-driven
path uses, and stop mapping a failed adapter to a text-only success.
MCP's ImageContent and CallToolResult take their payload type like
TextContent does; resolveScope reuses resolveTarget; needsLocator folds
into replayRequires.
2026-08-27 15:03:19 +02:00
Adrià Arrufat 193f15558c tools: declare image support at the call site, not in each consumer
Callers pass CallOpts.inline_image; execScreenshot rejects a path-less
call before navigating or rendering, which removes the per-consumer
guards and covers the model-driven tool path that had none. MCP image
content is a protocol type, the screenshot recording rule joins the
recorder's replayRequires predicate, and the viewport-to-Opts mapping,
node-scope ladder and save-path helpers are shared instead of copied.
2026-08-27 15:03:19 +02:00
Adrià Arrufat 56c6a0a7ab tools: add a screenshot tool
The PNG renderer was reachable from CDP and fetch --dump png only. The
tool renders the page or one node; with `path` it writes the file and
returns its location (agent, PandaScript, MCP), without it MCP returns
the image inline as base64 content. ToolResult carries the prepared
image so the transport streams it; the agent and script runtime reject
the inline form since their tool results are text. An inline screenshot
is not recorded, as it has no replayable form.
2026-08-27 15:03:19 +02:00
Adrià Arrufat 78f24b5da5 docs: prefer lightpanda run for replaying scripts
The `run` subcommand is the intended way to replay a recorded
PandaScript (`agent <script>.js` still works, but `run` carries no LLM
options and states the intent). Update the generated script-writing
skill, the recorder doc comment, and the README accordingly.
2026-08-06 13:02:17 +02:00
Adrià Arrufat 17c30c3a52 Merge branch 'main' into goto-waituntil 2026-08-05 13:08:23 +02:00
Adrià Arrufat 4a49030af6 chore: trim narrative comments 2026-08-04 22:36:56 +02:00
Adrià Arrufat b85fb7c6be simplify: recorder rewind, Tool.waitsForReadiness, single goto default
Post-review cleanup of the goto-waituntil branch:
- Recorder: emit the plain goto immediately and keep only a rewind offset +
  pre-scrubbed dcl line; a readiness wait recorded next rewinds and swaps the
  line. bytes() is infallible again, isEmpty() is gone, and the Agent.zig
  save-path changes revert. Shared renderCall() replaces the duplicated
  await/page. prologue; ObjectMap.clone + @tagName replace the hand-rolled
  clone loop and string literal.
- tools: Tool.waitsForReadiness as an exhaustive predicate (waitForScript was
  silently missing from the recorder's inline set); GotoParams.waitUntil is
  non-optional with the default resolved once; performGoto takes an options
  struct instead of two trailing nullables; the goto schema enum comes from
  Config.tagJsonArray like waitForState's.
- actions: comment states the invariant (polling needs a parsed document)
  rather than the symptom.
2026-08-04 22:26:13 +02:00
Adrià Arrufat a73cd97502 agent: record goto as domcontentloaded when a wait follows
The recorder holds each goto one step: if the next recorded call is
waitForSelector/waitForState, the emitted line carries
waitUntil=domcontentloaded (the follow-up wait covers readiness); anything
else, including extract and comments, flushes the plain load-wait form. An
explicit waitUntil is never rewritten. bytes() flushes a trailing held goto
and became fallible; isEmpty() replaces the len check in synthesizeSave (#3138).
2026-08-04 22:06:46 +02:00
Adrià Arrufat cd8ff37882 tools: add waitUntil option to goto
Optional param on the goto tool (load | domcontentloaded | networkalmostidle |
networkidle), default unchanged (load). Threaded through performGoto, startGoto
and the script runtime's pending-goto driver, which previously hard-coded
.load. Closes half of #3138.
2026-08-04 22:06:46 +02:00
Karl Seguin c86d969ce0 mem: prefer ensureTotalCapacityPrecise to reduce memory usage
When we know the precise final length, prefer ensureTotalCapacityPrecise over
ensureTotalCapacity. The latter goes through `growCapacity` which will allocate
~1.5x padding.
2026-08-04 17:44:05 +08:00
Karl Seguin c726b022e8 tests: improve test output and test log handling
1 - TEST_VERBOSE is now off by default
2 - There's a afterEach callback that is automatically run after each tests, it:
     a - clears the log filters
     b - resets the test arena
3 - LogFilter replace with
     a - testing.silenceLog(&.{...scopes...}); to silence all logs for the given
         scopes.
     b - testing.expectLog(&.{...scopes}); to set log expectations, 1 per log.
         The goal here isn't so much to expect logs (though, you can do that),
         but rather to silence an expected # of logs, without silencing more.
2026-07-31 07:30:25 +08:00
Adrià Arrufat e1202b7660 script: route console output through an optional sink
Since the Zig 0.16 merge, console.log and completion echoes from the
agent runtime tests reach the real stdout (0.15 silently swallowed
them), polluting the bench.json that CI captures from 'zig build test'
and breaking the perf-fmt job's JSON decoding.

Add Runtime.console_sink: when set, console lines write there instead
of stdout/stderr. The three printing tests now capture their output
and assert it, covering console argument joining and the top-level
return echo.
2026-07-22 17:00:40 +02:00
Karl Seguin 8e42d63c1c zig: Zig 0.16
Built against https://github.com/lightpanda-io/zig-v8-fork/tree/zig-0.16 but
it doesn't require a new v8 build.

Built against https://github.com/lightpanda-io/boringssl-zig/tree/zig-0.16
since the current fork we point to isn't updated.

A global std.Io instance, lp.io. Way easier this way and requires 0 changes to
our libcurl integration / event loop.

Network code uses a new layer that does what Zig 0.15's posix package used to
do. Again, quicker migration that way. But, as long as we have the global IO,
and given the half-baked nature of networking in std.Io 0.16, this just makes
sense. Things can be migrated as needed.

The std.time.* -> std.Io.Timestamp/Clock/Duration resulted in _a lot_ of
changes. ArrayList = .{} -> ArrayList -> .empty also resulted in a lot of
changes, but that's obviously superficial. As is the trimLeft/trimRight ->
trimStart/trimEnd rename.

Locking adopt the `Uncancelable` variants, e.g. mutex.lockUncancelable() to
preserve the error-free signature (and, because cancellation would be something
we'd have to put more thought into).

std.json.ObjectMap is now unmanaged, so the allocator had to be passed along.
However, there's still a deprecated managed variant of MemoryPool, so I switched
to it (we can do a small follow up PR to move to the unmanaged after).

I tried use_llvm = false, but it locks my computer, consuming RAM until MacOS
gives me a popup I've never seen before, begging me to start killing processes.

Agent and the networking stuff saw the most significant changes.
2026-07-22 13:26:03 +08:00
Adrià Arrufat 4307509094 agent: improve REPL syntax highlighting and terminal colors
- Add highlighting for JS functions, methods, types, and template
  interpolations.
- Introduce teal color and adjust cyan to bright cyan.
- Import `ansi.zig` directly instead of through `Terminal`.
- Add PandaScript semantics note to the agent's system prompt.
- Refactor markdown table rendering and streaming.
2026-07-15 11:02:46 +02:00
Adrià Arrufat 133e6ebe16 docs(skill): clarify HTTP concurrency limits
Update prose to explain the 6 per host limit and same-site fan-out.
2026-07-14 12:10:48 +02:00
Adrià Arrufat 8c0ec3d73a Merge branch 'main' into pandascript-skill 2026-07-14 12:06:05 +02:00
Karl Seguin 46e90a984f zig fmt 2026-07-14 11:55:59 +08:00
Karl Seguin 10e91d0503 test: Filter out expected error messages from tests
Please enter the commit message for your changes. Lines starting
2026-07-14 11:50:22 +08:00
Adrià Arrufat 3e801fb83b script: generate the PandaScript skill from the tool schemas
The /save script documentation lived as a hand-maintained string in
Agent.zig whose primitives table drifted whenever a tool changed. Move
it to src/script/skill.zig and render the reference (signatures, option
lists, enums, defaults, per-parameter descriptions) from Schema.all()
at first use, keeping the curated per-tool notes behind an exhaustive
switch on Tool so a new or renamed tool is a compile error until its
doc entry exists.

The rendered skill is shared by three consumers:
- the /save and revision system prompts (built lazily in Agent.zig)
- a new mcp://skill/pandascript resource
- `zig build skills`, which writes zig-out/skills/<name>/SKILL.md with
  Claude Code frontmatter via a registry-based generator exe, so
  future Lightpanda skills are one registry entry each

Schema.FieldEntry now retains per-parameter schema descriptions, which
previously existed only in the raw JSON.
2026-07-13 10:54:13 +02:00
Karl Seguin cc7d319ea5 Merge pull request #2874 from lightpanda-io/agent-regression
Add agent regression suite to CI (consumed from lightpanda-io/demo)
2026-07-08 09:19:00 +08:00
Adrià Arrufat f3f731ffb1 fix: setChecked double-toggled checkables via click activation
actions.setChecked set the input state and then dispatched a trusted
click, but the click's activation behavior (EventManager's
ActivationState) toggles a checkbox on every click dispatch — undoing
the state just set — and its commit path fires input/change, which
setChecked then fired a second time. Checkboxes always landed in the
opposite of the requested state with doubled events; radios only
appeared to work because radio activation always checks. It survived
because existing tests asserted the tool's report string, never the
resulting DOM state.

setChecked is now a no-op when the state already matches, otherwise it
performs the click (reusing actions.click) and verifies the state
landed — erroring before the click for radio+false (a click can never
uncheck a radio, and the click has observable page side effects) and
after it when a listener preventDefault()ed the toggle.

Caught by the checked.js golden in the demo agent regression suite.
The Runtime test now asserts DOM state and idempotency instead of
trusting the report string.
2026-07-07 15:43:28 +02:00
Karl Seguin 03f542006d v8, mem: Add v8 configuration option + heap limit protection
The main addition in this commit is that we hook into the Isolate's
AddNearHeapLimitCallback callback and try to force the isolate to shutdown
rather than letting v8 hit an OOM which would take down the entire process.

In support of this, we now support a `--v8-max-heap-mb` command line option to
set an explicit heap limit. As a simple way to test this feature, load a
relatively heavy JS page with `--v8-max-heap-mb 1`.

There's also a `--v8-flags-unsafe` which is a mechanism to pass arbitrary
flags to v8 via its `SetFlagsFromString`. The parameter is called `unsafe`
because some [of the many] configurable flags could conflict with how the
snapshot is built and result in crashes. The snapshot creator also gains a
`--v8-flags-unsafe` flag, so advance users COULD create their snapshot and
run lightpanda with the same set of flags.
2026-07-07 12:40:23 +08:00
Adrià Arrufat 985e7ded06 script: reject failed goto starts outside the browser isolate
startGoto runs under the browser's isolate; its error path rejected the
script-isolate resolver via a `return` inside that block, i.e. before
the deferred isolate exit — v8::Exception::Error on the wrong isolate,
SIGSEGV. Reached by any goto that fails to start: page.goto(null) (an
extract miss flowing into goto), malformed arguments.

Yield null from the isolate block and settle the resolver after it.
2026-07-04 15:25:21 +02:00
Adrià Arrufat cfd61ca69e tools: resolve $LP_* placeholders in startGoto
The script runtime's async goto path calls startGoto directly,
bypassing the substituteStringArgs pass that call() applies to every
other tool, so $LP_* placeholders in goto URLs (a base URL, a username
in the path) stayed literal and the navigation failed on replay.
2026-07-03 23:52:40 +02:00
Adrià Arrufat ae2359c1af script: fix segfault when a tool call triggers navigation
A press/click that submits a form or follows a link commits a
replacement Page inside the tool call itself (finalizeAction ->
awaitQueuedNavigation), freeing the Frame that
Session._tool_frame_override still pointed at; finalizeAction then
dereferenced it via requireFrame -> currentFrame. Any script whose
press/click navigated crashed on replay.

Store a frame id instead of a pointer and resolve it at every
currentFrame call. The replacement page keeps the frame id (see
commitPendingPage), so the same handle stays routable across the swap.
2026-07-03 23:52:29 +02:00
Adrià ArrufatandKarl Seguin c6e4715032 script-runtime: use ArrayList
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-06-29 08:05:05 +02:00
Adrià Arrufat 023c3c25a7 script-runtime: support concurrent page navigations
Changes `page.goto` to return a pending Promise instead of blocking
synchronously. Introduces a driver loop in `Runtime` to tick the
browser and settle pending navigations. This allows parallel gotos
and routes tool calls to their respective frames.
2026-06-28 08:25:03 +02:00
Karl Seguin da6cd66e36 design: multi-page session
Replaces the 1 (+1 inflight) page design of session for an unlimited number of
pages. The main goal is to support a more efficient async goto (1). Without this
commit, async goto has two implementation: multiple browser, which is resource
intensive (thread + isolate per page), bolting it onto sub-frames (like iframe
or popups). The issue with the 2nd version (which I originally suggested) is
that most resources are tied to the Page. So even if an async goto "page" (which
would map to a Frame) is released, most things stay in memory, including the
DOM (page.factory) and the V8::Context.

This new approach adds multiple page support to sessions. The advantage is
pretty clear: the existing memory model (page-tied resources) becomes a strength
of the design, rather than a weakness.

This change is not tirivla, but the diff is inflated by 2 large mechanial
changes, so it isn't _that_ big either. That said, I'd divide this into four
parts.

1 - The old concept of _active/_pending is now baked into the Page itself. A
Page has a `replaces: ?*Page = null` and `replacement: ?*Page = null` field.

2 - Because of #1 above, the Session now just has an `pages: ArrayList(*Page)`.
As much as possible, single-page APIs, like `removePage` no longer exists. We're
trying to present a consistent multi-page API. See #3.

3 - References to *Page and *Frame have always been dangerous. For example,
`lightpanda.fetch` has a block to scope `frame`:

```zig
{
    const frame = try session.createPage();
    // frame isn't safe to use after navigate, it can be swapped out
    _ = try frame.navigate(...)
}
```

While we still hand out *Page and *Frame (user-beware), some of the more
important APIs now take a frame_id which Session can resolve. Furthermore,
createPage now returns a PageHandle which is a safe wrapper around a page/frame.

4 - Two large mechanical changes were made:
  a - Many tests were superficially changed to account for new naming or use
      a new test-helper to preserve the single-page illusion (because 99% of
      tests _are_ single-page)
  b - Many CDP changes where `bc.session.currentFrame()` -> `bc.mainFrame()`).
      This isn't to say CDP changes are meaningless, but it's mostly 1 change
      about how the "main" target/frame_id is tracked (by CDP itself, rather
      than the Session) that required a number of superficial changes to
      accomodate

`Runner` remains largely single-page focused. Runner and some MCP/Agent tools
continue to be tied to the "currentFrame" or "primaryPage". The Runner is
something I want to address in a follow-up PR, but we need to figure out what
it means to "wait" for multiple pages.
As much as possible, Session becomes multi-page native and has no concept of
a special/first/primary/main page. Users of session become responsible for
tracking pages of interest.

This change is not trivial, but the diff is inflated by 2 large mechanical
changes:
1 - Many tests

(1) https://github.com/lightpanda-io/browser/pull/2759
2026-06-24 07:01:29 +08:00
Adrià Arrufat 144b3a58a0 terminal: support positional args for single-field schemas
Allows tools like `/getEnv` with a single optional field to accept
positional arguments. Also adds autocompletion and ghost hints for
live `LP_*` environment variables.

Additionally, fixes a memory leak in `settings.zig` when parsing
invalid ZON configurations.
2026-06-17 16:26:43 +02:00
Adrià Arrufat f52bbc0f48 agent: scrub remaining global-goto/extract refs from messages and docs 2026-06-16 08:10:43 +02:00
Adrià Arrufat 21477b39c9 script: introduce Page API with async goto
Replaces global blocking functions with a `Page` class. `page.goto` is
now asynchronous and must be awaited, while other methods remain
synchronous. Scripts are wrapped in an async IIFE to support top-level
await, and output is returned via `return <value>`.
2026-06-15 22:48:12 +02:00
Adrià Arrufat aeba63427e script: enforce positional arguments come first
Returns a specific `PositionalMustComeFirst` error when a positional
argument is placed after key-value pairs.
2026-06-11 18:24:44 +02:00
Adrià Arrufat 4b3e18fda2 agent: extract save helpers to save.zig (#2684)
* Revert "agent: enhance /save progress and verification feedback"

This reverts commit 54f467d1fa.

* Revert "agent: verify synthesized scripts during /save"

This reverts commit b141da30ca.

* agent: extract save helpers to save.zig

Moves pure helper functions related to the `/save` command (parsing,
validation, file writing, prompt shaping) from `Agent.zig` to a
dedicated `save.zig` module.
2026-06-09 19:06:09 +02:00
Adrià Arrufat b141da30ca agent: verify synthesized scripts during /save
Introduces a multi-step synthesis process for `/save` that derives a
logical JSON output schema and uses a dry-run runtime to verify
candidate scripts. The LLM can now run and self-correct its scripts
using a new `run_script` tool before finalizing the save.
2026-06-09 16:53:39 +02:00
Adrià Arrufat 2a7bda3608 string: add string.isOneOf and deduplicate helpers 2026-06-08 09:04:36 +02:00
Adrià Arrufat 3f0c6f6c09 terminal: autocomplete and hint enum values
Parses `enum` constraints from the schema and uses them to provide
tab-completions and ghost hints for enum fields in the REPL.
2026-06-05 16:58:19 +02:00
Adrià Arrufat 9d4f284c73 browser: add waitForState and default goto to load
Removes the `waitUntil` option from `goto` and other navigation tools,
making them default to the fast `load` event. Introduces a dedicated
`waitForState` tool to wait for specific load states on demand.
2026-06-05 16:26:19 +02:00
Adrià Arrufat b7e3a57294 script: generalize tool argument marshalling
Replaces manual tool-by-tool argument parsing in the runtime with a
schema-driven `marshalArgs` function. This supports merging trailing
options objects with leading positional arguments (e.g., `goto(url,
options)`). Also unifies positional definitions in `Schema` for both
the runtime and the recorder.
2026-06-04 14:35:03 +02:00
Adrià Arrufat a24a949028 extract: tolerate empty list selectors
An empty array is a valid result (e.g., zero comments). Only throw
an error if all schema selectors resolve to null.
2026-06-04 11:36:33 +02:00