EV-WebKit

 view release on metacpan or  search on metacpan

MANIFEST  view on Meta::CPAN

t/20-nav.t
t/21-teardown.t
t/30-script.t
t/40-find.t
t/40-frame-find.t
t/41-element-read.t
t/41-frame-ipc.t
t/42-element-write.t
t/43-wait.t
t/44-wait-teardown.t
t/45-auth-popup-viewport.t
t/45-lifecycle.t
t/46-collectability.t
t/46-find-js.t
t/47-nav-and-click.t
t/47-unicode.t
t/48-doc-promises.t
t/48-element-epoch.t
t/49-hostile.t
t/50-quit-flush.t
t/50-screenshot.t

README  view on Meta::CPAN

        ("set_cookie"/"cookies"/"clear_cookies"/"save_cookies"/"load_cookies
        ") -- and is the default for "wait_for"'s and "pdf"'s own "timeout"
        option, and "wait_for_navigation"'s. On expiry the operation's
        callback is resolved with "$err eq 'timeout'". Default 30.

        "download" is the deliberate exception: a large file legitimately
        takes longer than any per-operation timeout would allow, so a
        download is bounded only by the server and by "quit". Use
        "$dl->cancel" to end one early.

    "popups => 'follow' | 'block'"
        What to do with a navigation that asks for a new window. Documented
        in full under "EVENTS", with the rest of the options that shape how
        the browser reacts to the page.

    "user_agent => $string"
        Sets the initial User-Agent (equivalent to calling "set_user_agent"
        right after construction).

    "ephemeral => $bool"
        Use an ephemeral (in-memory, non-persistent) network session when

README  view on Meta::CPAN

        Requires the optional Proxy::Impersonate toolchain (which builds
        "curl-impersonate" via Alien::curlimpersonate); croaks if it is
        unavailable. Mutually exclusive with an explicit "proxy". Out of
        scope: WebSockets, HTTP/3. See "network_fingerprint" and
        "proxy_port".

    "on_error", "on_load", "on_navigate", "on_close", "on_console",
    "on_dialog", "on_policy", "on_file_chooser", "on_download",
    "on_authenticate", "on_request", "on_response"
        Event callbacks -- see "EVENTS", which documents each one and what
        it is handed. ("popups", above, is documented there too: it is what
        happens when no "on_policy" is set.)

METHODS
  Navigation
    Load pages and read basic document state.

   go
        $b->go($uri, sub { my ($result, $err) = @_; ... });

    Loads $uri. On success $result is true; on failure (or timeout) $err is

README  view on Meta::CPAN


        my $prev = $b->on_console;
        $b->on_console(sub { $prev->(@_) if $prev; ...also mine... });

    Croaks on a non-coderef. Enabling "on_console" after a page has loaded
    takes effect from the next navigation: the console proxy is a user
    script, and those are injected at document start.

EVENTS
    Optional callbacks passed to "new", and the one option that shapes what
    the browser does when it has none ("popups"):

    "on_error => sub { my ($err) = @_ }"
        Called for a navigation failure that has no "go"/"load_html"
        callback waiting for it (e.g. a stray "load-failed" signal).
        Ordinary navigation failures go to that call's own callback instead,
        not here.

    "on_load => sub { }"
        Called with no arguments when a navigation started through this API
        ("go", "load_html", "back", "forward", or "reload") finishes

README  view on Meta::CPAN

        you own the loop. For a browser window whose closing should end the
        program, that is the whole handler:

            my $b = EV::WebKit->new(chrome => 1, on_close => sub { EV::break });
            ...
            EV::run;   # returns when the window is closed

        Unlike "on_console"/"on_dialog"/"on_policy", "on_close" is delivered
        on a clean EV tick, so calling "EV::break" directly from it is safe.

    "popups => 'follow' | 'block'"
        What to do with a navigation that asks for a new window -- a
        "target="_blank"" link, or "window.open". WebKit allows such a
        navigation and then asks for a window to put it in; a one-view
        browser has none to give, so the click would otherwise do nothing
        whatsoever: no navigation, no error, no event. The default 'follow'
        takes it in this view instead. 'block' keeps it dropped.

        The two arrive by different routes, which matters if you set
        "on_policy". A "target="_blank"" link is a policy decision, so that
        handler sees it first, with "type => 'new-window-action'", and can
        refuse it outright with "$p->block".

        "window.open" is not a window request WebKitGTK asks about, so no
        "new-window-action" ever arrives for it -- there is nothing to
        refuse at that stage. Under the default 'follow', though, the popup
        is re-issued in this view as an ordinary navigation, and that does
        reach "on_policy" as a "navigation-action" carrying the popup's own
        url. So selective filtering is possible for both mechanisms; only
        the decision "type" differs. (Under 'block' the popup is dropped
        before any navigation, so "on_policy" sees nothing at all.)

        One caveat if you are testing this: WebKit's own popup blocker drops
        a "window.open" made from an inline script with no user gesture
        behind it, and then nothing reaches "on_policy" either. Drive it
        from a real click -- "$el->click" counts -- as a page would.

        What "on_policy" does not decide is where an allowed one goes:
        WebKit asks for a window afterwards either way, and this option is
        what answers. So an "on_policy" that allows a "target="_blank"" link
        still lands it in this view under 'follow', and still drops it under
        'block'. If you want to route it yourself, "$p->block" and navigate
        from a clean tick -- starting a navigation inside the handler runs

lib/EV/WebKit.pm  view on Meta::CPAN

    $r = ($r * 10 + $_) % 4294967296 for split //, $_[0];
    return $r;
}

my %KNOWN_NEW = map { $_ => 1 } qw(
    timeout window display
    on_load on_error on_close on_navigate on_console on_dialog on_policy
    on_file_chooser on_download on_request on_response on_authenticate
    data_dir cache_dir ephemeral cookie_jar jar_format
    proxy user_agent devtools title chrome
    fingerprint network_fingerprint seed popups fonts
);

# fonts => a fontconfig file, or the directories to build one from. Returns
# undef, or { conf => $abs_file, bind => [ [$path, $read_only], ... ] }.
#
# The web process runs under bubblewrap and cannot see a path nobody mounted
# for it: setting FONTCONFIG_FILE alone changes nothing at all (measured --
# identical text metrics), because fontconfig inside the sandbox never opens
# the file. So every path the config names has to be handed to
# add_path_to_sandbox as well, which is why this returns the list rather than

lib/EV/WebKit.pm  view on Meta::CPAN

                my $err = $@;
                eval { $dlg->dismiss };   # best-effort: give the page a definite answer
                warn "EV::WebKit: on_dialog callback died: $err";
            }
        }
        else { $dlg->dismiss }
        return 1;  # handled -- suppress WebKit's own blocking native dialog
    });

    $self->{on_policy} = $o{on_policy};
    $self->{popups} = $o{popups} // 'follow';
    Carp::croak("EV::WebKit: popups must be 'follow' or 'block'")
        unless $self->{popups} eq 'follow' || $self->{popups} eq 'block';
    $view->signal_connect('decide-policy' => sub {
        my (undef, $decision, $type_nick) = @_;   # type_nick: navigation-action/new-window-action/response
        local $IN_DISPATCH = 1;          # on_policy runs nested in WebKit's dispatch frame -- see quit
        my $self = $wself or return 0;   # $self gone: WebKit applies its own default (allow)
        return 0 if $self->{_dead};      # torn down: let WebKit apply its own default
        # A target=_blank navigation is ALLOWED by default and then silently
        # dropped: WebKit goes on to ask for a window through 'create', which a
        # one-view browser does not answer, so the click does nothing at all --
        # no navigation, no error, no event. Follow it in this view instead, on
        # a clean tick, since starting a navigation inside WebKit's own dispatch
        # frame is the wedge $IN_DISPATCH exists for. popups => 'block' keeps
        # the drop; an on_policy handler owns the decision itself and is left
        # alone. window.open does NOT arrive here -- see the 'create' handler.
        if (($type_nick // '') eq 'new-window-action'
            && $self->{popups} eq 'follow' && !$self->{on_policy}) {
            my $nu = eval { $decision->get_navigation_action->get_request->get_uri };
            if (defined $nu && length $nu) {
                $decision->ignore;
                weaken(my $ws = $self);
                $self->_defer(sub { my ($u) = @_; my $b = $ws or return; $b->go($u) }, $nu);
                return 1;
            }
        }
        return 0 unless $self->{on_policy};   # not handled -- WebKit applies its own default (allow)
        # WebKitNavigationPolicyDecision (navigation-action/new-window-action) only

lib/EV/WebKit.pm  view on Meta::CPAN

            return 1;
        }
        $info->allow unless $info->{done};   # default allow if handler didn't decide
        return 1;   # handled
    });

    # window.open() is not a policy decision in WebKitGTK: decide-policy never
    # fires for it at all (measured), and WebKit asks for a window through
    # 'create' instead. A one-view browser that answers nothing there leaves the
    # call returning null with no navigation, no error and no event -- so
    # popups => 'follow' has to be honoured here as well as in decide-policy,
    # which only ever saw target=_blank. on_policy is deliberately NOT consulted:
    # there is no decision object to give it, and inventing one would document a
    # policy hook that cannot allow, ignore or download.
    $view->signal_connect(create => sub {
        my (undef, $nav) = @_;
        local $IN_DISPATCH = 1;
        my $self = $wself or return undef;
        return undef if $self->{_dead} || $self->{popups} ne 'follow';
        my $nu = eval { $nav->get_request->get_uri };
        return undef unless defined $nu && length $nu;
        weaken(my $ws = $self);
        $self->_defer(sub { my ($u) = @_; my $b = $ws or return; $b->go($u) }, $nu);
        return undef;   # no second view: the navigation happens in this one
    });

    # HTTP (and proxy) authentication. WITHOUT this connected at all, a 401
    # challenge is answered by nobody: WebKit waits, the navigation resolves
    # 'timeout' after the full instance timeout, and status() is undef -- so the

lib/EV/WebKit.pm  view on Meta::CPAN

everything routed through a frame, C<resize>, C<html>, C<screenshot>, C<pdf>,
and the cookie operations
(C<set_cookie>/C<cookies>/C<clear_cookies>/C<save_cookies>/C<load_cookies>) --
and is the default for C<wait_for>'s and C<pdf>'s own C<timeout> option, and C<wait_for_navigation>'s. On expiry the operation's
callback is resolved with C<$err eq 'timeout'>. Default C<30>.

C<download> is the deliberate exception: a large file legitimately takes longer
than any per-operation timeout would allow, so a download is bounded only by
the server and by C<quit>. Use C<< $dl->cancel >> to end one early.

=item C<< popups => 'follow' | 'block' >>

What to do with a navigation that asks for a new window. Documented in full
under L</EVENTS>, with the rest of the options that shape how the browser
reacts to the page.

=item C<< user_agent => $string >>

Sets the initial User-Agent (equivalent to calling C<set_user_agent> right
after construction).

lib/EV/WebKit.pm  view on Meta::CPAN

nor a settable C<GTlsDatabase>), which is why the C<IGNORE> policy is used.

Requires the optional L<Proxy::Impersonate> toolchain (which builds
C<curl-impersonate> via L<Alien::curlimpersonate>); croaks if it is unavailable.
Mutually exclusive with an explicit C<proxy>. Out of scope: WebSockets, HTTP/3.
See L</network_fingerprint> and L</proxy_port>.

=item C<on_error>, C<on_load>, C<on_navigate>, C<on_close>, C<on_console>, C<on_dialog>, C<on_policy>, C<on_file_chooser>, C<on_download>, C<on_authenticate>, C<on_request>, C<on_response>

Event callbacks -- see L</"EVENTS">, which documents each one and what it is
handed. (C<popups>, above, is documented there too: it is what happens when no
C<on_policy> is set.)

=back

=head1 METHODS

=head2 Navigation

Load pages and read basic document state.

lib/EV/WebKit.pm  view on Meta::CPAN

    my $prev = $b->on_console;
    $b->on_console(sub { $prev->(@_) if $prev; ...also mine... });

Croaks on a non-coderef. Enabling C<on_console> after a page has loaded takes
effect from the B<next> navigation: the console proxy is a user script, and
those are injected at document start.

=head1 EVENTS

Optional callbacks passed to C<new>, and the one option that shapes what the
browser does when it has none (C<popups>):

=over 4

=item C<< on_error => sub { my ($err) = @_ } >>

Called for a navigation failure that has no C<go>/C<load_html> callback
waiting for it (e.g. a stray C<load-failed> signal). Ordinary navigation
failures go to that call's own callback instead, not here.

=item C<< on_load => sub { } >>

lib/EV/WebKit.pm  view on Meta::CPAN

own the loop. For a browser window whose closing should end the program, that
is the whole handler:

    my $b = EV::WebKit->new(chrome => 1, on_close => sub { EV::break });
    ...
    EV::run;   # returns when the window is closed

Unlike C<on_console>/C<on_dialog>/C<on_policy>, C<on_close> is delivered on a
clean EV tick, so calling C<EV::break> directly from it is safe.

=item C<< popups => 'follow' | 'block' >>

What to do with a navigation that asks for a new window -- a C<target="_blank">
link, or C<window.open>. WebKit allows such a navigation and then asks for a
window to put it in; a one-view browser has none to give, so the click would
otherwise do nothing whatsoever: no navigation, no error, no event. The default
C<'follow'> takes it in this view instead. C<'block'> keeps it dropped.

The two arrive by different routes, which matters if you set C<on_policy>. A
C<target="_blank"> link is a policy decision, so that handler sees it first,
with C<< type => 'new-window-action' >>, and can refuse it outright with
C<< $p->block >>.

C<window.open> is B<not> a window request WebKitGTK asks about, so no
C<new-window-action> ever arrives for it -- there is nothing to refuse at that
stage. Under the default C<'follow'>, though, the popup is re-issued in this
view as an ordinary navigation, and that B<does> reach C<on_policy> as a
C<navigation-action> carrying the popup's own url. So selective filtering is
possible for both mechanisms; only the decision C<type> differs. (Under
C<'block'> the popup is dropped before any navigation, so C<on_policy> sees
nothing at all.)

One caveat if you are testing this: WebKit's own popup blocker drops a
C<window.open> made from an inline script with no user gesture behind it, and
then nothing reaches C<on_policy> either. Drive it from a real click --
C<< $el->click >> counts -- as a page would.

What C<on_policy> does not decide is B<where> an allowed one goes: WebKit asks
for a window afterwards either way, and this option is what answers. So an
C<on_policy> that allows a C<target="_blank"> link still lands it in this view
under C<'follow'>, and still drops it under C<'block'>. If you want to route it
yourself, C<< $p->block >> and navigate from a clean tick -- starting a
navigation inside the handler runs it in WebKit's own dispatch frame.

t/45-auth-popup-viewport.t  view on Meta::CPAN

    $b->go("http://127.0.0.1:$port/secret", sub { $err = $_[1]; $secs = EV::time - $t0; EV::break });
    TWK::run_with_timeout(30);
    ok(defined $err, "on_authenticate that $name: the navigation fails");
    cmp_ok($secs // 99, '<', 5, "on_authenticate that $name: ...promptly, not at the instance timeout");
    like($err // '', $want, "on_authenticate that $name: ...saying which route it took");
    $b->quit;
}

# --- window.open ------------------------------------------------------------
# window.open is NOT a policy decision in WebKitGTK: decide-policy never fires
# for it, so the popups option had to be honoured on 'create' as well. Before
# that it did nothing at all -- no navigation, no error, no event.
for my $case ([follow => 'POPUP'], [block => undef]) {
    my ($mode, $want) = @$case;
    my $b = EV::WebKit->new(window => [400,300], timeout => 15, popups => $mode);
    # window.open's return value is recorded in a page global, so the 'block'
    # case can assert the call was actually MADE -- otherwise "the title is not
    # POPUP" is equally true of a page where the click never happened.
    $b->load_html('<html><body><button id=x onclick=\'window.opened = window.open('
                . '"http://127.0.0.1:' . $port . '/pop","_blank") ? "won" : "null"\'>go</button></body></html>', sub {
        $b->find('#x', sub {
            $_[0]->click(sub { my $t; $t = EV::timer(2.5, 0, sub { undef $t; EV::break }) });
        });
    });
    TWK::run_with_timeout(30);
    if (defined $want) {
        is($b->title, $want, "popups => $mode follows window.open in this view");
        like($b->uri // '', qr{/pop$}, "...landing on the requested uri");
    }
    else {
        my $opened;
        $b->script('return String(window.opened);', sub { $opened = $_[0]; EV::break });
        TWK::run_with_timeout(20);
        is($opened, 'null', "popups => $mode: window.open was called and returned null");
        isnt($b->title // '', 'POPUP', "popups => $mode drops window.open");
    }
    $b->quit;
}

# --- resize / zoom ----------------------------------------------------------
{
    my $b = EV::WebKit->new(window => [800,600], timeout => 15);
    my (%g, @steps);
    my $nxt; $nxt = sub { my $s = shift @steps or return EV::break; $s->() };
    @steps = (

t/45-auth-popup-viewport.t  view on Meta::CPAN

    like($@, qr/name => value pairs/, '...rather than defaulting persist silently');
    ok(!eval { $auth->login('u', 'p', 'permanent'); 1 }, 'login croaks on a bare option value too');
    like($@, qr/name => value pairs/, '...with the same message');
    is(scalar(@w), 0, '...and neither warned from inside the module');
    ok(!eval { $auth->login('u', 'p', persist => 'forever'); 1 }, 'a bad persist value still croaks');
    like($@, qr/persist must be/, '...saying the allowed values');
}

# window.open is not a WINDOW request WebKitGTK asks about, so no
# new-window-action ever arrives for it -- but under the default
# popups => 'follow' the popup is re-issued in this view as an ordinary
# navigation, which on_policy DOES see and can refuse. The POD used to say it
# "never reaches on_policy at all", which talks a user out of selective popup
# filtering entirely.
#
# The open must come from a real user gesture: WebKit's popup blocker drops a
# window.open called from an inline script, and then nothing reaches policy at
# all (measured -- that is why this clicks a button rather than running script).
{
    our @POL;
    my $pb = EV::WebKit->new(window => [300,200], ephemeral => 1, timeout => 10,
        on_policy => sub {
            my ($p) = @_;
            push @POL, ($p->{type} // '?') . '(' . ($p->{uri} // '?') . ')';
            $p->allow;
        });

t/45-auth-popup-viewport.t  view on Meta::CPAN

    ok($el, 'premise: the opener page loaded and its button was found');

  SKIP: {
        skip 'no button', 2 unless $el;
        @POL = ();
        $el->click(sub { EV::break });
        TWK::run_with_timeout(20);
        { my $settle = EV::timer(2.0, 0, sub { EV::break }); EV::run }
        my $seen = join ' ', @POL;
        like($seen, qr{navigation-action\(pw://host/target\)},
             'a window.open popup reaches on_policy as a navigation-action')
            or diag("policy events: $seen");
        unlike($seen, qr{new-window-action\(pw://host/target\)},
               '...and never as a new-window-action, which is what "not a policy decision" meant');
    }
    $pb->quit;
}

done_testing;

t/82-navigate.t  view on Meta::CPAN

is(scalar(@nav), 1, 'load_html fires on_navigate');

# 4) nothing after quit
@nav = ();
$b->quit;
for (1 .. 3) { my $t = EV::timer(0.05, 0, sub { EV::break }); EV::run }
is(scalar(@nav), 0, 'no on_navigate after quit');

# A target=_blank click asks WebKit for a NEW WINDOW. WebKit allows it and then
# emits 'create' to get one; a single-view browser answers nothing, so the click
# used to do nothing at all -- no navigation, no error, no event. popups
# defaults to 'follow', which takes it in this view instead.
{
    my $b = EV::WebKit->new(window => [400,300], timeout => 8);
    $b->mock_scheme('mock', sub {
        my ($uri) = @_;
        return ('<html><body><a id="x" href="mock://target" target="_blank">go</a></body></html>',
                'text/html') if $uri =~ m{mock://start};
        return ('<html><head><title>TARGET</title></head><body>arrived</body></html>', 'text/html');
    });
    my ($uri, $title);

t/82-navigate.t  view on Meta::CPAN

                    undef $t; $uri = $b->uri; $title = $b->title; EV::break });
            });
        });
    });
    TWK::run_with_timeout(20);
    is($uri, 'mock://target', 'a target=_blank click navigates this view');
    is($title, 'TARGET', '...and the page really loaded');
    $b->quit;
}

# popups => 'block' keeps the drop, for a caller that wants it.
{
    my $b = EV::WebKit->new(window => [400,300], timeout => 8, popups => 'block');
    $b->mock_scheme('mock', sub {
        my ($uri) = @_;
        return ('<html><body><a id="x" href="mock://target" target="_blank">go</a></body></html>',
                'text/html') if $uri =~ m{mock://start};
        return ('<html><head><title>TARGET</title></head><body>arrived</body></html>', 'text/html');
    });
    my $uri;
    $b->go('mock://start', sub {
        $b->find('#x', sub {
            my ($el) = @_;
            $el->click(sub {
                my $t; $t = EV::timer(2.5, 0, sub { undef $t; $uri = $b->uri; EV::break });
            });
        });
    });
    TWK::run_with_timeout(20);
    is($uri, 'mock://start', "popups => 'block' leaves the view where it was");
    $b->quit;
}

# and the option is validated rather than silently ignored
{
    my $bad = eval { EV::WebKit->new(window => [200,200], popups => 'maybe'); 1 };
    ok(!$bad, 'an unknown popups value croaks');
    like($@, qr/popups must be/, '...naming the option');
}


done_testing;



( run in 0.855 second using v1.01-cache-2.11-cpan-364913b4093 )