AnyEvent-MCP
view release on metacpan or search on metacpan
t/22_webbrowser.t view on Meta::CPAN
my ($peer, $packet) = @_;
print {$peer->{socket}} Protocol::WebSocket::Frame->new (
type => "text", masked => 1, buffer => JSON::XS::encode_json ($packet),
)->to_bytes or die $!;
}
sub read_ws {
my ($peer) = @_;
my $deadline = Time::HiRes::time () + 5;
while (Time::HiRes::time () < $deadline) {
while (defined (my $bytes = $peer->{parser}->next_bytes)) {
my $opcode = $peer->{parser}->opcode;
return [$opcode, $opcode == 1 ? JSON::XS::decode_json ($bytes) : $bytes] unless $opcode == 10;
}
next unless IO::Select->new ($peer->{socket})->can_read (.1);
my $n = sysread $peer->{socket}, my $bytes, 8192;
defined $n && $n or die "Unexpected WebSocket EOF\n";
$peer->{parser}->append ($bytes);
}
die "WebSocket reply timed out\n";
}
sub connect_page {
my ($target, $instance) = @_;
my ($peer, $header) = upgrade "secondary", $target;
$header =~ /101/ or die "WebSocket upgrade failed: $header";
send_ws $peer, ["mcp-webbrowser", 1, 1, $instance, { url => "https://page.example/", origin => "https://page.example", title => "Page" }];
my $welcome = read_ws ($peer)->[1];
($peer, $welcome)
}
my ($bad, $header) = upgrade "wrong", "bad";
unlike $header, qr/101/, "bad token cannot upgrade";
close $bad->{socket};
my ($invalid_target, $invalid_header) = upgrade "primary", "%ZZ";
unlike $invalid_header, qr/101/, "invalid target cannot upgrade";
close $invalid_target->{socket};
my ($early) = upgrade "primary", "early";
send_ws $early, ["state", { url => "https://page.example/", title => "early" }];
my $closed = read_ws $early;
is $closed->[0], 8, "commands before hello close the connection";
is unpack ("n", $closed->[1]), 1002, "protocol rejection uses status 1002";
like substr ($closed->[1], 2), qr/Invalid browser hello/, "protocol rejection carries its reason";
close $early->{socket};
my ($wrong_version) = upgrade "primary", "wrong-version";
send_ws $wrong_version, ["mcp-webbrowser", 2, 0, "wrong-version",
{ url => "https://page.example/", title => "Page" }];
my $rejected = read_ws $wrong_version;
is $rejected->[0], 8, "incompatible browser protocol closes the connection";
is unpack ("n", $rejected->[1]), 1002, "incompatible protocol uses status 1002";
like substr ($rejected->[1], 2), qr/Unsupported browser protocol 2\.0 \(server 1\.1\)/,
"close frame explains the version mismatch";
close $wrong_version->{socket};
my ($old_client) = upgrade "primary", "old-client";
send_ws $old_client, ["mcp-webbrowser", 1, 0, "old-document",
{ url => 'https://user:pass@page.example/path?query', title => "Old client" }];
is_deeply read_ws ($old_client)->[1], ["welcome", "old-client", "old-document", 1],
"compatible old browser protocol 1.0 connects";
is +(tabs ())->[0]{origin}, "https://page.example", "broker derives origin from URL without credentials";
my $old_id = ++$serial;
send_rpc $old_id, "tools/call", { name => "execute", arguments => { target => "old-client", code => "1+1" } };
my $old_call = read_ws ($old_client)->[1];
is $old_call->[3]{code}, "1+1", "old browser client retains JavaScript execution";
send_ws $old_client, ["reply", $old_call->[1], JSON::XS::true, 2];
is read_rpc ($old_id)->{result}{structuredContent}{value}, 2, "old client execution completes";
is_deeply tool ("list-tab-operations", { target => "old-client" })->{result}{structuredContent}{operations},
[], "unsupported tab operations discover as empty for old client";
like tool ("call-tab-operation", { target => "old-client", operation => "test" })
->{result}{content}[0]{text}, qr/require protocol 1\.1/,
"calling unsupported old-client operation fails without disconnect";
send_ws $old_client, ["quit"];
is read_ws ($old_client)->[0], 8, "old browser client quits normally";
close $old_client->{socket};
my ($newer_client) = upgrade "primary", "newer-client";
send_ws $newer_client, ["mcp-webbrowser", 1, 2, "newer-document",
{ url => "https://page.example/", title => "Newer client" }];
is_deeply read_ws ($newer_client)->[1], ["welcome", "newer-client", "newer-document", 1],
"compatible newer minor protocol connects";
send_ws $newer_client, ["quit"];
is read_ws ($newer_client)->[0], 8, "newer client quits normally";
close $newer_client->{socket};
my ($one, $welcome) = connect_page "tab", "document-one";
is_deeply $welcome, ["welcome", "tab", "document-one", 1], "authenticated page is registered";
is $welcome->[3], 1, "first document starts at revision 1";
like JSON::XS::encode_json ($welcome), qr/\A\["welcome","tab","document-one",1\]\z/,
"welcome revision is a JSON number";
my ($two, $second) = connect_page "other", "document-two";
is_deeply $second, ["welcome", "other", "document-two", 1], "different tabs have their own revisions";
my ($again, $replacement) = connect_page "other", "document-two";
is_deeply $replacement, ["welcome", "other", "document-two", 1], "same document reconnect retains revision";
like JSON::XS::encode_json ($replacement), qr/\A\["welcome","other","document-two",1\]\z/,
"reconnect welcome still uses numeric revision";
is read_ws ($two)->[0], 8, "reconnect closes old connection";
close $two->{socket};
my $registered = tabs;
is_deeply [map $_->{target}, @$registered], ["other", "tab"], "tabs enumerates registered tabs";
send_ws $one, ["state", { url => "https://page.example/next?secret=not-for-logs#fragment", origin => "https://page.example", title => "Updated" }];
for (1 .. 100) {
$registered = tabs;
last if $registered->[1]{title} eq "Updated";
Time::HiRes::sleep (.01);
}
is $registered->[1]{title}, "Updated", "state updates replace reported metadata";
is $registered->[1]{revision}, 1, "tabs expose the document revision";
like JSON::XS::encode_json ({revision => $registered->[1]{revision}}),
qr/"revision":1/, "tabs serializes document revision as a JSON number";
policy "allow", "https://page.example";
my $origin_id = ++$serial;
send_rpc $origin_id, "tools/call", { name => "execute", arguments => { target => "tab", code => "allowed_origin()" } };
my $origin_call = read_ws ($one)->[1];
is $origin_call->[3]{code}, "allowed_origin()", "exact origin allows execution";
send_ws $one, ["reply", $origin_call->[1], JSON::XS::true, "yes"];
is read_rpc ($origin_id)->{result}{structuredContent}{value}, "yes", "origin-scoped permission dispatch succeeded";
send_ws $one, ["state", { url => "https://other.example/", origin => "https://other.example", title => "Cross-origin" }];
for (1 .. 100) {
( run in 1.021 second using v1.01-cache-2.11-cpan-3f962061493 )