@vostride/agent-qa-plugin-hyperbrowser 0.0.0-stage → 0.3.2
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.
- package/LICENSE.md +105 -0
- package/NOTICE.md +11 -0
- package/README.md +184 -2
- package/dist/main.js +7 -0
- package/dist/main.js.map +1 -0
- package/dist/options.schema.json +21 -0
- package/dist/plugin-6PEkbMWx.js +198 -0
- package/dist/plugin-6PEkbMWx.js.map +1 -0
- package/dist/plugin.js +1 -0
- package/dist/settings.schema.json +123 -0
- package/package.json +69 -4
- package/skill/SKILL.md +106 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Functional Source License, Version 1.1, ALv2 Future License
|
|
2
|
+
|
|
3
|
+
## Abbreviation
|
|
4
|
+
|
|
5
|
+
FSL-1.1-ALv2
|
|
6
|
+
|
|
7
|
+
## Notice
|
|
8
|
+
|
|
9
|
+
Copyright 2026 Pranshu Chittora
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
22
|
+
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the Apache License, Version 2.0 that is effective on the second anniversary of
|
|
91
|
+
the date we make the Software available. On or after that date, you may use the
|
|
92
|
+
Software under the Apache License, Version 2.0, in which case the following
|
|
93
|
+
will apply:
|
|
94
|
+
|
|
95
|
+
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
|
96
|
+
this file except in compliance with the License.
|
|
97
|
+
|
|
98
|
+
You may obtain a copy of the License at
|
|
99
|
+
|
|
100
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
101
|
+
|
|
102
|
+
Unless required by applicable law or agreed to in writing, software distributed
|
|
103
|
+
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
104
|
+
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
105
|
+
specific language governing permissions and limitations under the License.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# agent-qa Notice
|
|
2
|
+
|
|
3
|
+
agent-qa is provided by Pranshu Chittora.
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Pranshu Chittora
|
|
6
|
+
|
|
7
|
+
agent-qa is licensed under the Functional Source License, Version 1.1, ALv2 Future License (FSL-1.1-ALv2). See `LICENSE.md` for the full license terms.
|
|
8
|
+
|
|
9
|
+
Under the Grant of Future License in `LICENSE.md`, each version of the Software is also granted under the Apache License, Version 2.0 effective on the second anniversary of the date that version was made available.
|
|
10
|
+
|
|
11
|
+
Third-party dependencies are distributed under their own license terms. This notice covers agent-qa itself and does not replace or modify notices required by third-party dependency owners.
|
package/README.md
CHANGED
|
@@ -1,3 +1,185 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vostride/agent-qa-plugin-hyperbrowser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The agent-qa plugin for [Hyperbrowser](https://hyperbrowser.ai) cloud browsers. Before a web test
|
|
4
|
+
connects, it creates a Hyperbrowser session and answers agent-qa with the session's CDP URL, and
|
|
5
|
+
after the test it stops the session. Hyperbrowser runs Chromium only.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Install the plugin at exactly the version of agent-qa the project runs:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install --save-dev --save-exact @vostride/agent-qa-plugin-hyperbrowser@<agent-qa version>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`agent-qa plugins list` prints this line, with the version filled in, when the package is missing
|
|
16
|
+
or its version differs from agent-qa's.
|
|
17
|
+
|
|
18
|
+
## Configure
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
plugins:
|
|
22
|
+
- name: hyperbrowser
|
|
23
|
+
package: "@vostride/agent-qa-plugin-hyperbrowser"
|
|
24
|
+
options:
|
|
25
|
+
apiKey: "{{secret:HYPERBROWSER_API_KEY}}"
|
|
26
|
+
|
|
27
|
+
registry:
|
|
28
|
+
remotes:
|
|
29
|
+
- name: hyperbrowser
|
|
30
|
+
plugin: hyperbrowser
|
|
31
|
+
- name: hyperbrowser-unrecorded
|
|
32
|
+
plugin: hyperbrowser
|
|
33
|
+
settings:
|
|
34
|
+
recording: false
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A test runs there with `use.browser.remote: hyperbrowser` in the config, a suite or the test.
|
|
38
|
+
|
|
39
|
+
### Options
|
|
40
|
+
|
|
41
|
+
Options belong to the `plugins` entry and describe the account.
|
|
42
|
+
|
|
43
|
+
| Option | Default | Meaning |
|
|
44
|
+
| -------- | ----------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
45
|
+
| `apiKey` | - | The Hyperbrowser API key, a credential, required. Write it as a `{{secret:<name>}}` reference. |
|
|
46
|
+
| `apiUrl` | `https://api.hyperbrowser.ai` | Hyperbrowser's API. It must use `https://` with no user or password, unless it points at this machine. |
|
|
47
|
+
|
|
48
|
+
The key is sent only as the `x-api-key` header. It never appears in a message, a log or a check
|
|
49
|
+
line.
|
|
50
|
+
|
|
51
|
+
### Settings
|
|
52
|
+
|
|
53
|
+
Settings belong to each remote and describe one place.
|
|
54
|
+
|
|
55
|
+
| Setting | Default | Meaning |
|
|
56
|
+
| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
57
|
+
| `timeoutMinutes` | `720` | Minutes before Hyperbrowser stops the session (1 to 720), always sent as `timeoutMinutes`. It overrides the team default in Hyperbrowser's settings. |
|
|
58
|
+
| `recording` | - | Hyperbrowser's web recording, sent as `enableWebRecording` when set. Unset keeps the account's default, which records. `false` turns it off. |
|
|
59
|
+
| `stealth` | `false` | Hyperbrowser's stealth mode, sent as `useStealth`. |
|
|
60
|
+
| `proxy` | `false` | Hyperbrowser's proxy: `true`, or `{ country, state, city }` with any of the three. Sent as `useProxy`, with `proxyCountry`, `proxyState` and `proxyCity` for the fields given. |
|
|
61
|
+
| `solveCaptchas` | `false` | Hyperbrowser's CAPTCHA solving, sent as `solveCaptchas`. |
|
|
62
|
+
| `adblock` | `false` | Hyperbrowser's ad blocking, sent as `adblock`. |
|
|
63
|
+
| `trackers` | `false` | Hyperbrowser's tracker blocking, sent as `trackers`. |
|
|
64
|
+
| `annoyances` | `false` | Hyperbrowser's annoyance blocking, sent as `annoyances`. |
|
|
65
|
+
| `acceptCookies` | `false` | Hyperbrowser's automatic acceptance of cookie banners, sent as `acceptCookies`. |
|
|
66
|
+
| `screen` | - | The browser screen, `{ width, height }` in pixels, sent as `screen`. Unset keeps Hyperbrowser's default. |
|
|
67
|
+
| `region` | - | The Hyperbrowser region, for example `us-east` or `europe-west`, sent as `region`. |
|
|
68
|
+
| `profile` | - | A Hyperbrowser profile id, loaded read-only and sent as `profile: { id, persistChanges: false }`. Needs `context: reuse`. |
|
|
69
|
+
| `extensions` | - | Hyperbrowser extension ids loaded into the browser, sent as `extensionIds`. |
|
|
70
|
+
| `extra` | - | Any other Hyperbrowser request field, merged last. |
|
|
71
|
+
|
|
72
|
+
`agent-qa plugins describe hyperbrowser` prints the same tables from the schemas the package ships,
|
|
73
|
+
and `--json` prints them as JSON.
|
|
74
|
+
|
|
75
|
+
## How the request body is built
|
|
76
|
+
|
|
77
|
+
The create request (`POST /api/session`) is built in this order, each part overriding the one
|
|
78
|
+
before:
|
|
79
|
+
|
|
80
|
+
1. the lifetime, `timeoutMinutes`;
|
|
81
|
+
2. the features that change the page: stealth, proxies, CAPTCHA solving, ad, tracker and annoyance
|
|
82
|
+
blocking and cookie auto-accept. Each is sent off unless a setting turns it on, because a test
|
|
83
|
+
should see the app the way its users do;
|
|
84
|
+
3. the proxy fields: `proxy: true` sends `useProxy: true`, and a proxy object also sends
|
|
85
|
+
`proxyCountry`, `proxyState` and `proxyCity` for the fields it gives;
|
|
86
|
+
4. the other settings above, in Hyperbrowser's field names, each only when it is set;
|
|
87
|
+
5. `extra`, last, so a pass-through key overrides both a default and a setting.
|
|
88
|
+
|
|
89
|
+
For example, `settings: { extra: { useUltraStealth: true } }` asks for Hyperbrowser's Enterprise
|
|
90
|
+
stealth mode. Hyperbrowser's own proxy servers for Enterprise accounts go through `extra` too.
|
|
91
|
+
agent-qa refuses a password there unless it is a `{{secret:<name>}}` reference:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
settings:
|
|
95
|
+
extra:
|
|
96
|
+
proxyServer: "http://proxy.example.com:8080"
|
|
97
|
+
proxyServerUsername: "<proxy user>"
|
|
98
|
+
proxyServerPassword: "{{secret:HYPERBROWSER_PROXY_PASSWORD}}"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Proxies and CAPTCHA solving need a paid Hyperbrowser plan. When a feature is not on the plan,
|
|
102
|
+
Hyperbrowser refuses the create, as described under Failures.
|
|
103
|
+
|
|
104
|
+
## Lifetime and disconnects
|
|
105
|
+
|
|
106
|
+
By default the plugin asks for a 720-minute session, the longest Hyperbrowser allows.
|
|
107
|
+
`timeoutMinutes` lowers it; a value outside 1 to 720 is refused with path
|
|
108
|
+
`settings.timeoutMinutes` before any session is created.
|
|
109
|
+
|
|
110
|
+
The plugin never asks Hyperbrowser for keep-alive, so Hyperbrowser stops a session whose connection
|
|
111
|
+
drops. A session left behind by a killed job, where agent-qa and the plugin die together, stops
|
|
112
|
+
when its connection does. A session whose create was still in flight when the job was killed was
|
|
113
|
+
never connected and has no idle timeout, so it runs until `timeoutMinutes`; lower
|
|
114
|
+
`timeoutMinutes` to bound it. agent-qa also stops every session itself after the test; the
|
|
115
|
+
lifetime only bounds a session that agent-qa could not stop.
|
|
116
|
+
|
|
117
|
+
## Recording
|
|
118
|
+
|
|
119
|
+
Hyperbrowser records sessions by default and keeps the replay on its dashboard, behind its login.
|
|
120
|
+
agent-qa's masking does not reach that replay, so a secret a test types may show in it. Set
|
|
121
|
+
`recording: false` on a remote to turn recording off for its sessions:
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
registry:
|
|
125
|
+
remotes:
|
|
126
|
+
- name: hyperbrowser-unrecorded
|
|
127
|
+
plugin: hyperbrowser
|
|
128
|
+
settings:
|
|
129
|
+
recording: false
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Video replay is off unless you ask for it with `extra: { enableVideoWebRecording: true }`. It needs
|
|
133
|
+
web recording, so do not combine it with `recording: false`.
|
|
134
|
+
|
|
135
|
+
## Profiles
|
|
136
|
+
|
|
137
|
+
`profile` loads a Hyperbrowser profile by id, and the plugin sends `persistChanges: false`, so
|
|
138
|
+
nothing a test does is saved back. A profile lives in the browser's first context, so the remote
|
|
139
|
+
needs `context: reuse`. Without it, the remote is refused with kind `refused` and path
|
|
140
|
+
`settings.profile`.
|
|
141
|
+
|
|
142
|
+
## Failures
|
|
143
|
+
|
|
144
|
+
| Hyperbrowser answer | Kind |
|
|
145
|
+
| --------------------- | ------------- |
|
|
146
|
+
| no answer | `unreachable` |
|
|
147
|
+
| `401` | `auth` |
|
|
148
|
+
| `429` | `capacity` |
|
|
149
|
+
| any other non-success | `refused` |
|
|
150
|
+
|
|
151
|
+
Each failure is one line naming the status, the request and Hyperbrowser's reason, with the
|
|
152
|
+
`Retry-After` wait on a `429`. A `403` most often means a feature is not on the plan, for example
|
|
153
|
+
`Hyperbrowser answered 403 to POST /api/session: Proxy usage requires a paid plan`; turn the
|
|
154
|
+
setting off or change the plan.
|
|
155
|
+
|
|
156
|
+
Hyperbrowser chooses the session id, so a create that fails has no session to stop. When
|
|
157
|
+
Hyperbrowser answers a session without a `wsEndpoint`, the remote is refused and agent-qa stops
|
|
158
|
+
the session by the id it answered.
|
|
159
|
+
|
|
160
|
+
On release, `2xx` and `404` both count as stopped, so stopping a session Hyperbrowser already
|
|
161
|
+
stopped after a disconnect succeeds.
|
|
162
|
+
|
|
163
|
+
## Links and the live view
|
|
164
|
+
|
|
165
|
+
The session link is the session's page in Hyperbrowser's dashboard, which sits behind
|
|
166
|
+
Hyperbrowser's login and shows the replay. The plugin returns it only when it is an `https://` URL.
|
|
167
|
+
|
|
168
|
+
Hyperbrowser's live view URL and the session token let anyone who has them watch and drive the
|
|
169
|
+
session, so both are credentials: the plugin adds them, with the CDP URL, to the values agent-qa
|
|
170
|
+
masks, and never returns either as the link.
|
|
171
|
+
|
|
172
|
+
## Account check
|
|
173
|
+
|
|
174
|
+
`agent-qa doctor` runs the plugin's `check`, which reads the active sessions with
|
|
175
|
+
`GET /api/sessions?status=active&page=1` and opens none:
|
|
176
|
+
|
|
177
|
+
- An accepted key reads `the API key was accepted`, followed by the number of active sessions
|
|
178
|
+
when Hyperbrowser gives it, for example `2 active sessions`.
|
|
179
|
+
- A refused key warns `the API key was refused`.
|
|
180
|
+
- Any other failure warns with its one line, for example
|
|
181
|
+
`Hyperbrowser could not be reached at <apiUrl>`.
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
FSL-1.1-ALv2. See `LICENSE.md` and `NOTICE.md` in the package.
|
package/dist/main.js
ADDED
package/dist/main.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.js","names":["plugin"],"sources":["../src/main.ts"],"sourcesContent":["import { runPlugin } from '@vostride/agent-qa-plugin-kit'\nimport plugin from './plugin.js'\n\nrunPlugin(plugin)\n"],"mappings":";;;AAGA,UAAUA,cAAM"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"apiKey": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"credential": true,
|
|
8
|
+
"description": "The Hyperbrowser API key"
|
|
9
|
+
},
|
|
10
|
+
"apiUrl": {
|
|
11
|
+
"default": "https://api.hyperbrowser.ai",
|
|
12
|
+
"description": "Hyperbrowser's API, https:// unless it points at this machine",
|
|
13
|
+
"type": "string",
|
|
14
|
+
"format": "uri"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"required": [
|
|
18
|
+
"apiKey"
|
|
19
|
+
],
|
|
20
|
+
"additionalProperties": false
|
|
21
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { PluginError, checkEndpoints, createVendorApi, credential, definePlugin, z } from "@vostride/agent-qa-plugin-kit";
|
|
2
|
+
// src/hyperbrowser-api.ts
|
|
3
|
+
const HyperbrowserSessionSchema = z.object({
|
|
4
|
+
id: z.string().min(1),
|
|
5
|
+
wsEndpoint: z.string().min(1),
|
|
6
|
+
liveUrl: z.string().min(1).optional(),
|
|
7
|
+
token: z.string().min(1).optional(),
|
|
8
|
+
sessionUrl: z.string().min(1).optional()
|
|
9
|
+
});
|
|
10
|
+
function createHyperbrowserApi(input) {
|
|
11
|
+
const api = createVendorApi({
|
|
12
|
+
vendor: "Hyperbrowser",
|
|
13
|
+
baseUrl: input.apiUrl,
|
|
14
|
+
headers: { "x-api-key": input.apiKey }
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
async createSession(body) {
|
|
18
|
+
const response = await api.send("POST", "api/session", {
|
|
19
|
+
headers: {
|
|
20
|
+
"content-type": "application/json",
|
|
21
|
+
accept: "application/json"
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify(body)
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) throw await api.failure(response, "POST /api/session");
|
|
26
|
+
return api.json(response);
|
|
27
|
+
},
|
|
28
|
+
async stop(id, signal) {
|
|
29
|
+
const response = await api.send("PUT", `api/session/${encodeURIComponent(id)}/stop`, {
|
|
30
|
+
headers: { accept: "application/json" },
|
|
31
|
+
signal
|
|
32
|
+
});
|
|
33
|
+
if (response.ok || response.status === 404) {
|
|
34
|
+
await response.body?.cancel();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
throw await api.failure(response, "PUT /api/session/{id}/stop");
|
|
38
|
+
},
|
|
39
|
+
async activeSessions(signal) {
|
|
40
|
+
const response = await api.send("GET", "api/sessions?status=active&page=1", {
|
|
41
|
+
headers: { accept: "application/json" },
|
|
42
|
+
signal
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok) throw await api.failure(response, "GET /api/sessions");
|
|
45
|
+
return api.json(response);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// src/plugin.ts
|
|
50
|
+
const options = z.strictObject({
|
|
51
|
+
apiKey: credential("The Hyperbrowser API key"),
|
|
52
|
+
apiUrl: z.url().default("https://api.hyperbrowser.ai").describe("Hyperbrowser's API, https:// unless it points at this machine")
|
|
53
|
+
});
|
|
54
|
+
const settings = z.strictObject({
|
|
55
|
+
timeoutMinutes: z.int().min(1).max(720).default(720).describe("Minutes before Hyperbrowser stops the session"),
|
|
56
|
+
recording: z.boolean().optional().describe("Hyperbrowser's web recording; unset keeps the account's default, which records"),
|
|
57
|
+
stealth: z.boolean().default(false).describe("Hyperbrowser's stealth mode, which changes what pages see"),
|
|
58
|
+
proxy: z.union([z.boolean(), z.strictObject({
|
|
59
|
+
country: z.string().min(1).optional(),
|
|
60
|
+
state: z.string().min(1).optional(),
|
|
61
|
+
city: z.string().min(1).optional()
|
|
62
|
+
})]).default(false).describe("Hyperbrowser's proxy, true or { country, state, city }"),
|
|
63
|
+
solveCaptchas: z.boolean().default(false).describe("Hyperbrowser's CAPTCHA solving"),
|
|
64
|
+
adblock: z.boolean().default(false).describe("Hyperbrowser's ad blocking"),
|
|
65
|
+
trackers: z.boolean().default(false).describe("Hyperbrowser's tracker blocking"),
|
|
66
|
+
annoyances: z.boolean().default(false).describe("Hyperbrowser's annoyance blocking"),
|
|
67
|
+
acceptCookies: z.boolean().default(false).describe("Hyperbrowser's automatic acceptance of cookie banners"),
|
|
68
|
+
screen: z.strictObject({
|
|
69
|
+
width: z.int().positive(),
|
|
70
|
+
height: z.int().positive()
|
|
71
|
+
}).optional().describe("The browser screen size in pixels"),
|
|
72
|
+
region: z.string().min(1).optional().describe("The Hyperbrowser region the session runs in"),
|
|
73
|
+
profile: z.string().min(1).optional().describe("A Hyperbrowser profile id loaded read-only; needs context: reuse on the remote"),
|
|
74
|
+
extensions: z.array(z.string().min(1)).min(1).optional().describe("Hyperbrowser extension ids loaded into the browser"),
|
|
75
|
+
extra: z.record(z.string(), z.unknown()).optional().describe("Fields merged last into the session create request, overriding the rest")
|
|
76
|
+
});
|
|
77
|
+
function checkSettings(input, session) {
|
|
78
|
+
if (input.profile !== void 0 && (session.platform !== "web" || session.context !== "reuse")) throw new PluginError("profile loads only into a reused context; set context: reuse on the remote.", {
|
|
79
|
+
kind: "refused",
|
|
80
|
+
path: "settings.profile"
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function proxyBody(proxy) {
|
|
84
|
+
if (typeof proxy === "boolean") return { useProxy: proxy };
|
|
85
|
+
return {
|
|
86
|
+
useProxy: true,
|
|
87
|
+
...proxy.country === void 0 ? {} : { proxyCountry: proxy.country },
|
|
88
|
+
...proxy.state === void 0 ? {} : { proxyState: proxy.state },
|
|
89
|
+
...proxy.city === void 0 ? {} : { proxyCity: proxy.city }
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function createBody(input) {
|
|
93
|
+
return {
|
|
94
|
+
timeoutMinutes: input.timeoutMinutes,
|
|
95
|
+
useStealth: input.stealth,
|
|
96
|
+
...proxyBody(input.proxy),
|
|
97
|
+
solveCaptchas: input.solveCaptchas,
|
|
98
|
+
adblock: input.adblock,
|
|
99
|
+
trackers: input.trackers,
|
|
100
|
+
annoyances: input.annoyances,
|
|
101
|
+
acceptCookies: input.acceptCookies,
|
|
102
|
+
...input.recording === void 0 ? {} : { enableWebRecording: input.recording },
|
|
103
|
+
...input.screen === void 0 ? {} : { screen: input.screen },
|
|
104
|
+
...input.region === void 0 ? {} : { region: input.region },
|
|
105
|
+
...input.profile === void 0 ? {} : { profile: {
|
|
106
|
+
id: input.profile,
|
|
107
|
+
persistChanges: false
|
|
108
|
+
} },
|
|
109
|
+
...input.extensions === void 0 ? {} : { extensionIds: input.extensions },
|
|
110
|
+
...input.extra
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function sessionIdOf(answer) {
|
|
114
|
+
if (typeof answer !== "object" || answer === null || !("id" in answer)) return void 0;
|
|
115
|
+
const id = answer.id;
|
|
116
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
117
|
+
}
|
|
118
|
+
function dashboardLink(url) {
|
|
119
|
+
if (url === void 0) return void 0;
|
|
120
|
+
return URL.parse(url)?.protocol === "https:" ? url : void 0;
|
|
121
|
+
}
|
|
122
|
+
const HandleSchema = z.object({ sessionId: z.string().min(1) });
|
|
123
|
+
const ActiveSessionsSchema = z.object({ totalCount: z.int().nonnegative() });
|
|
124
|
+
function activeLine(count) {
|
|
125
|
+
return count === 1 ? "1 active session" : `${String(count)} active sessions`;
|
|
126
|
+
}
|
|
127
|
+
function failureLine(error) {
|
|
128
|
+
return error instanceof Error ? error.message : String(error);
|
|
129
|
+
}
|
|
130
|
+
var plugin_default = definePlugin({
|
|
131
|
+
options,
|
|
132
|
+
settings,
|
|
133
|
+
serves: ["cdp"],
|
|
134
|
+
skill: "./skill",
|
|
135
|
+
initialize({ options }) {
|
|
136
|
+
checkEndpoints(options, [{
|
|
137
|
+
path: "apiUrl",
|
|
138
|
+
scheme: "https"
|
|
139
|
+
}]);
|
|
140
|
+
},
|
|
141
|
+
async check({ options }, ctx) {
|
|
142
|
+
let answer;
|
|
143
|
+
try {
|
|
144
|
+
answer = await createHyperbrowserApi(options).activeSessions(ctx.signal);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof PluginError && error.kind === "auth") return {
|
|
147
|
+
state: "warn",
|
|
148
|
+
lines: ["the API key was refused"]
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
state: "warn",
|
|
152
|
+
lines: [failureLine(error)]
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const count = ActiveSessionsSchema.safeParse(answer);
|
|
156
|
+
return {
|
|
157
|
+
state: "ok",
|
|
158
|
+
lines: ["the API key was accepted", ...count.success ? [activeLine(count.data.totalCount)] : []]
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
async acquire({ options, settings, session }) {
|
|
162
|
+
if (session.platform !== "web" || session.engine !== "chromium") {
|
|
163
|
+
const what = session.platform === "web" ? session.engine : session.platform;
|
|
164
|
+
throw new PluginError(`Hyperbrowser runs Chromium web sessions only, not ${what}.`, { kind: "refused" });
|
|
165
|
+
}
|
|
166
|
+
checkSettings(settings, session);
|
|
167
|
+
const created = await createHyperbrowserApi(options).createSession(createBody(settings));
|
|
168
|
+
const answer = HyperbrowserSessionSchema.safeParse(created);
|
|
169
|
+
if (!answer.success) {
|
|
170
|
+
const sessionId = sessionIdOf(created);
|
|
171
|
+
throw new PluginError(`Hyperbrowser answered a session without ${sessionId === void 0 ? "an id" : "a usable wsEndpoint"}.`, {
|
|
172
|
+
kind: "refused",
|
|
173
|
+
...sessionId === void 0 ? {} : { handle: { sessionId } }
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const { id, wsEndpoint, liveUrl, token, sessionUrl } = answer.data;
|
|
177
|
+
const link = dashboardLink(sessionUrl);
|
|
178
|
+
return {
|
|
179
|
+
handle: { sessionId: id },
|
|
180
|
+
protocol: "cdp",
|
|
181
|
+
url: wsEndpoint,
|
|
182
|
+
mask: [
|
|
183
|
+
wsEndpoint,
|
|
184
|
+
liveUrl,
|
|
185
|
+
token
|
|
186
|
+
].filter((value) => value !== void 0),
|
|
187
|
+
...link === void 0 ? {} : { link }
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
async release({ options, handle }, ctx) {
|
|
191
|
+
const parsed = HandleSchema.safeParse(handle);
|
|
192
|
+
if (!parsed.success) throw new PluginError("A Hyperbrowser handle names its session by sessionId.", { kind: "refused" });
|
|
193
|
+
await createHyperbrowserApi(options).stop(parsed.data.sessionId, ctx.signal);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
export { plugin_default as t };
|
|
197
|
+
|
|
198
|
+
//# sourceMappingURL=plugin-6PEkbMWx.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-6PEkbMWx.js","names":[],"sources":["../src/hyperbrowser-api.ts","../src/plugin.ts"],"sourcesContent":["import { createVendorApi, z } from '@vostride/agent-qa-plugin-kit'\n\nexport const HyperbrowserSessionSchema = z.object({\n id: z.string().min(1),\n wsEndpoint: z.string().min(1),\n liveUrl: z.string().min(1).optional(),\n token: z.string().min(1).optional(),\n sessionUrl: z.string().min(1).optional(),\n})\n\ninterface HyperbrowserApiInput {\n apiUrl: string\n apiKey: string\n}\n\nexport interface HyperbrowserApi {\n createSession(body: Record<string, unknown>): Promise<unknown>\n stop(id: string, signal?: AbortSignal): Promise<void>\n activeSessions(signal?: AbortSignal): Promise<unknown>\n}\n\nexport function createHyperbrowserApi(input: HyperbrowserApiInput): HyperbrowserApi {\n const api = createVendorApi({\n vendor: 'Hyperbrowser',\n baseUrl: input.apiUrl,\n headers: { 'x-api-key': input.apiKey },\n })\n\n return {\n async createSession(body) {\n const response = await api.send('POST', 'api/session', {\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify(body),\n })\n if (!response.ok) throw await api.failure(response, 'POST /api/session')\n return api.json(response)\n },\n\n async stop(id, signal) {\n const response = await api.send('PUT', `api/session/${encodeURIComponent(id)}/stop`, {\n headers: { accept: 'application/json' },\n signal,\n })\n if (response.ok || response.status === 404) {\n await response.body?.cancel()\n return\n }\n throw await api.failure(response, 'PUT /api/session/{id}/stop')\n },\n\n async activeSessions(signal) {\n const response = await api.send('GET', 'api/sessions?status=active&page=1', {\n headers: { accept: 'application/json' },\n signal,\n })\n if (!response.ok) throw await api.failure(response, 'GET /api/sessions')\n return api.json(response)\n },\n }\n}\n","import {\n checkEndpoints,\n credential,\n definePlugin,\n PluginError,\n z,\n type AcquireInput,\n} from '@vostride/agent-qa-plugin-kit'\nimport { createHyperbrowserApi, HyperbrowserSessionSchema } from './hyperbrowser-api.js'\n\nconst HYPERBROWSER_API_URL = 'https://api.hyperbrowser.ai'\n\nconst options = z.strictObject({\n apiKey: credential('The Hyperbrowser API key'),\n apiUrl: z\n .url()\n .default(HYPERBROWSER_API_URL)\n .describe(\"Hyperbrowser's API, https:// unless it points at this machine\"),\n})\n\nconst settings = z.strictObject({\n timeoutMinutes: z\n .int()\n .min(1)\n .max(720)\n .default(720)\n .describe('Minutes before Hyperbrowser stops the session'),\n recording: z\n .boolean()\n .optional()\n .describe(\"Hyperbrowser's web recording; unset keeps the account's default, which records\"),\n stealth: z\n .boolean()\n .default(false)\n .describe(\"Hyperbrowser's stealth mode, which changes what pages see\"),\n proxy: z\n .union([\n z.boolean(),\n z.strictObject({\n country: z.string().min(1).optional(),\n state: z.string().min(1).optional(),\n city: z.string().min(1).optional(),\n }),\n ])\n .default(false)\n .describe(\"Hyperbrowser's proxy, true or { country, state, city }\"),\n solveCaptchas: z.boolean().default(false).describe(\"Hyperbrowser's CAPTCHA solving\"),\n adblock: z.boolean().default(false).describe(\"Hyperbrowser's ad blocking\"),\n trackers: z.boolean().default(false).describe(\"Hyperbrowser's tracker blocking\"),\n annoyances: z.boolean().default(false).describe(\"Hyperbrowser's annoyance blocking\"),\n acceptCookies: z\n .boolean()\n .default(false)\n .describe(\"Hyperbrowser's automatic acceptance of cookie banners\"),\n screen: z\n .strictObject({ width: z.int().positive(), height: z.int().positive() })\n .optional()\n .describe('The browser screen size in pixels'),\n region: z.string().min(1).optional().describe('The Hyperbrowser region the session runs in'),\n profile: z\n .string()\n .min(1)\n .optional()\n .describe('A Hyperbrowser profile id loaded read-only; needs context: reuse on the remote'),\n extensions: z\n .array(z.string().min(1))\n .min(1)\n .optional()\n .describe('Hyperbrowser extension ids loaded into the browser'),\n extra: z\n .record(z.string(), z.unknown())\n .optional()\n .describe('Fields merged last into the session create request, overriding the rest'),\n})\n\ntype HyperbrowserSettings = z.output<typeof settings>\ntype Session = AcquireInput<unknown, unknown>['session']\n\nfunction checkSettings(input: HyperbrowserSettings, session: Session): void {\n if (input.profile !== undefined && (session.platform !== 'web' || session.context !== 'reuse')) {\n throw new PluginError(\n 'profile loads only into a reused context; set context: reuse on the remote.',\n { kind: 'refused', path: 'settings.profile' },\n )\n }\n}\n\nfunction proxyBody(proxy: HyperbrowserSettings['proxy']): Record<string, unknown> {\n if (typeof proxy === 'boolean') return { useProxy: proxy }\n return {\n useProxy: true,\n ...(proxy.country === undefined ? {} : { proxyCountry: proxy.country }),\n ...(proxy.state === undefined ? {} : { proxyState: proxy.state }),\n ...(proxy.city === undefined ? {} : { proxyCity: proxy.city }),\n }\n}\n\nfunction createBody(input: HyperbrowserSettings): Record<string, unknown> {\n return {\n timeoutMinutes: input.timeoutMinutes,\n useStealth: input.stealth,\n ...proxyBody(input.proxy),\n solveCaptchas: input.solveCaptchas,\n adblock: input.adblock,\n trackers: input.trackers,\n annoyances: input.annoyances,\n acceptCookies: input.acceptCookies,\n ...(input.recording === undefined ? {} : { enableWebRecording: input.recording }),\n ...(input.screen === undefined ? {} : { screen: input.screen }),\n ...(input.region === undefined ? {} : { region: input.region }),\n ...(input.profile === undefined\n ? {}\n : { profile: { id: input.profile, persistChanges: false } }),\n ...(input.extensions === undefined ? {} : { extensionIds: input.extensions }),\n ...input.extra,\n }\n}\n\nfunction sessionIdOf(answer: unknown): string | undefined {\n if (typeof answer !== 'object' || answer === null || !('id' in answer)) return undefined\n const id = answer.id\n return typeof id === 'string' && id !== '' ? id : undefined\n}\n\nfunction dashboardLink(url: string | undefined): string | undefined {\n if (url === undefined) return undefined\n return URL.parse(url)?.protocol === 'https:' ? url : undefined\n}\n\nconst HandleSchema = z.object({ sessionId: z.string().min(1) })\n\nconst ActiveSessionsSchema = z.object({ totalCount: z.int().nonnegative() })\n\nfunction activeLine(count: number): string {\n return count === 1 ? '1 active session' : `${String(count)} active sessions`\n}\n\nfunction failureLine(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\nexport default definePlugin({\n options,\n settings,\n serves: ['cdp'],\n skill: './skill',\n\n initialize({ options }) {\n checkEndpoints(options, [{ path: 'apiUrl', scheme: 'https' }])\n },\n\n async check({ options }, ctx) {\n let answer: unknown\n try {\n answer = await createHyperbrowserApi(options).activeSessions(ctx.signal)\n } catch (error) {\n if (error instanceof PluginError && error.kind === 'auth') {\n return { state: 'warn', lines: ['the API key was refused'] }\n }\n return { state: 'warn', lines: [failureLine(error)] }\n }\n const count = ActiveSessionsSchema.safeParse(answer)\n return {\n state: 'ok',\n lines: [\n 'the API key was accepted',\n ...(count.success ? [activeLine(count.data.totalCount)] : []),\n ],\n }\n },\n\n async acquire({ options, settings, session }) {\n if (session.platform !== 'web' || session.engine !== 'chromium') {\n const what = session.platform === 'web' ? session.engine : session.platform\n throw new PluginError(`Hyperbrowser runs Chromium web sessions only, not ${what}.`, {\n kind: 'refused',\n })\n }\n checkSettings(settings, session)\n // The create is not cancelled: once Hyperbrowser has a session, the handle must come back to\n // be stopped, and the kit releases a handle that arrives after a cancel was answered.\n const created = await createHyperbrowserApi(options).createSession(createBody(settings))\n const answer = HyperbrowserSessionSchema.safeParse(created)\n if (!answer.success) {\n const sessionId = sessionIdOf(created)\n const missing = sessionId === undefined ? 'an id' : 'a usable wsEndpoint'\n throw new PluginError(`Hyperbrowser answered a session without ${missing}.`, {\n kind: 'refused',\n ...(sessionId === undefined ? {} : { handle: { sessionId } }),\n })\n }\n const { id, wsEndpoint, liveUrl, token, sessionUrl } = answer.data\n const link = dashboardLink(sessionUrl)\n return {\n handle: { sessionId: id },\n protocol: 'cdp' as const,\n url: wsEndpoint,\n mask: [wsEndpoint, liveUrl, token].filter((value) => value !== undefined),\n ...(link === undefined ? {} : { link }),\n }\n },\n\n async release({ options, handle }, ctx) {\n const parsed = HandleSchema.safeParse(handle)\n if (!parsed.success) {\n throw new PluginError('A Hyperbrowser handle names its session by sessionId.', {\n kind: 'refused',\n })\n }\n await createHyperbrowserApi(options).stop(parsed.data.sessionId, ctx.signal)\n },\n})\n"],"mappings":"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"}
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as default } from "./plugin-6PEkbMWx.js";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"timeoutMinutes": {
|
|
6
|
+
"default": 720,
|
|
7
|
+
"description": "Minutes before Hyperbrowser stops the session",
|
|
8
|
+
"type": "integer",
|
|
9
|
+
"minimum": 1,
|
|
10
|
+
"maximum": 720
|
|
11
|
+
},
|
|
12
|
+
"recording": {
|
|
13
|
+
"description": "Hyperbrowser's web recording; unset keeps the account's default, which records",
|
|
14
|
+
"type": "boolean"
|
|
15
|
+
},
|
|
16
|
+
"stealth": {
|
|
17
|
+
"default": false,
|
|
18
|
+
"description": "Hyperbrowser's stealth mode, which changes what pages see",
|
|
19
|
+
"type": "boolean"
|
|
20
|
+
},
|
|
21
|
+
"proxy": {
|
|
22
|
+
"default": false,
|
|
23
|
+
"description": "Hyperbrowser's proxy, true or { country, state, city }",
|
|
24
|
+
"anyOf": [
|
|
25
|
+
{
|
|
26
|
+
"type": "boolean"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"country": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"minLength": 1
|
|
34
|
+
},
|
|
35
|
+
"state": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"minLength": 1
|
|
38
|
+
},
|
|
39
|
+
"city": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"minLength": 1
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"additionalProperties": false
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"solveCaptchas": {
|
|
49
|
+
"default": false,
|
|
50
|
+
"description": "Hyperbrowser's CAPTCHA solving",
|
|
51
|
+
"type": "boolean"
|
|
52
|
+
},
|
|
53
|
+
"adblock": {
|
|
54
|
+
"default": false,
|
|
55
|
+
"description": "Hyperbrowser's ad blocking",
|
|
56
|
+
"type": "boolean"
|
|
57
|
+
},
|
|
58
|
+
"trackers": {
|
|
59
|
+
"default": false,
|
|
60
|
+
"description": "Hyperbrowser's tracker blocking",
|
|
61
|
+
"type": "boolean"
|
|
62
|
+
},
|
|
63
|
+
"annoyances": {
|
|
64
|
+
"default": false,
|
|
65
|
+
"description": "Hyperbrowser's annoyance blocking",
|
|
66
|
+
"type": "boolean"
|
|
67
|
+
},
|
|
68
|
+
"acceptCookies": {
|
|
69
|
+
"default": false,
|
|
70
|
+
"description": "Hyperbrowser's automatic acceptance of cookie banners",
|
|
71
|
+
"type": "boolean"
|
|
72
|
+
},
|
|
73
|
+
"screen": {
|
|
74
|
+
"description": "The browser screen size in pixels",
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"width": {
|
|
78
|
+
"type": "integer",
|
|
79
|
+
"exclusiveMinimum": 0,
|
|
80
|
+
"maximum": 9007199254740991
|
|
81
|
+
},
|
|
82
|
+
"height": {
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"exclusiveMinimum": 0,
|
|
85
|
+
"maximum": 9007199254740991
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"required": [
|
|
89
|
+
"width",
|
|
90
|
+
"height"
|
|
91
|
+
],
|
|
92
|
+
"additionalProperties": false
|
|
93
|
+
},
|
|
94
|
+
"region": {
|
|
95
|
+
"description": "The Hyperbrowser region the session runs in",
|
|
96
|
+
"type": "string",
|
|
97
|
+
"minLength": 1
|
|
98
|
+
},
|
|
99
|
+
"profile": {
|
|
100
|
+
"description": "A Hyperbrowser profile id loaded read-only; needs context: reuse on the remote",
|
|
101
|
+
"type": "string",
|
|
102
|
+
"minLength": 1
|
|
103
|
+
},
|
|
104
|
+
"extensions": {
|
|
105
|
+
"description": "Hyperbrowser extension ids loaded into the browser",
|
|
106
|
+
"minItems": 1,
|
|
107
|
+
"type": "array",
|
|
108
|
+
"items": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"minLength": 1
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"extra": {
|
|
114
|
+
"description": "Fields merged last into the session create request, overriding the rest",
|
|
115
|
+
"type": "object",
|
|
116
|
+
"propertyNames": {
|
|
117
|
+
"type": "string"
|
|
118
|
+
},
|
|
119
|
+
"additionalProperties": {}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"additionalProperties": false
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vostride/agent-qa-plugin-hyperbrowser",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "agent-qa plugin for Hyperbrowser: creates a Hyperbrowser browser session before a test connects and stops it after.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"agent-qa",
|
|
8
|
+
"agent-qa-plugin",
|
|
9
|
+
"hyperbrowser"
|
|
10
|
+
],
|
|
11
|
+
"license": "FSL-1.1-ALv2",
|
|
12
|
+
"homepage": "https://github.com/vostride/agent-qa#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/vostride/agent-qa/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/vostride/agent-qa.git",
|
|
19
|
+
"directory": "plugins/hyperbrowser"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"registry": "https://registry.npmjs.org/"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"skill",
|
|
35
|
+
"LICENSE.md",
|
|
36
|
+
"NOTICE.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsdown && node scripts/emit.mjs",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"clean": "rm -rf dist .turbo"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@vostride/agent-qa-plugin-kit": "0.3.2"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "~24.19.2",
|
|
49
|
+
"@vostride/agent-qa-arch": "0.3.2",
|
|
50
|
+
"tsdown": "~0.23.4",
|
|
51
|
+
"typescript": "~7.0.2",
|
|
52
|
+
"vitest": "~5.0.5"
|
|
53
|
+
},
|
|
54
|
+
"agent-qa": {
|
|
55
|
+
"plugin": {
|
|
56
|
+
"kind": "remote",
|
|
57
|
+
"runs": "process",
|
|
58
|
+
"protocol": 1,
|
|
59
|
+
"entry": "./dist/main.js",
|
|
60
|
+
"serves": [
|
|
61
|
+
"cdp"
|
|
62
|
+
],
|
|
63
|
+
"methods": [
|
|
64
|
+
"check"
|
|
65
|
+
],
|
|
66
|
+
"options": "./dist/options.schema.json",
|
|
67
|
+
"settings": "./dist/settings.schema.json",
|
|
68
|
+
"skill": "./skill"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-qa-plugin-hyperbrowser
|
|
3
|
+
description: Use when configuring agent-qa web tests to run in Hyperbrowser cloud browsers through the Hyperbrowser plugin, or when a Hyperbrowser remote is refused.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Hyperbrowser remotes for agent-qa
|
|
7
|
+
|
|
8
|
+
The Hyperbrowser plugin gives an agent-qa web test a Hyperbrowser browser session. Before the test
|
|
9
|
+
connects it creates a session through Hyperbrowser's API, and after the test it stops the session.
|
|
10
|
+
Hyperbrowser runs Chromium only. Stealth, proxies, CAPTCHA solving, ad, tracker and annoyance
|
|
11
|
+
blocking and cookie auto-accept are off unless a setting turns them on, so a test sees the app the
|
|
12
|
+
way its users do.
|
|
13
|
+
|
|
14
|
+
## Declare the plugin and a remote
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
plugins:
|
|
18
|
+
- name: hyperbrowser
|
|
19
|
+
package: "@vostride/agent-qa-plugin-hyperbrowser"
|
|
20
|
+
options:
|
|
21
|
+
apiKey: "{{secret:HYPERBROWSER_API_KEY}}"
|
|
22
|
+
|
|
23
|
+
registry:
|
|
24
|
+
remotes:
|
|
25
|
+
- name: hyperbrowser
|
|
26
|
+
plugin: hyperbrowser
|
|
27
|
+
- name: hyperbrowser-unrecorded
|
|
28
|
+
plugin: hyperbrowser
|
|
29
|
+
settings:
|
|
30
|
+
recording: false
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- The `plugins` entry holds the account: `apiKey`, which is required, and `apiUrl`, which defaults
|
|
34
|
+
to `https://api.hyperbrowser.ai`. Write the key as a `{{secret:<name>}}` reference to a name in
|
|
35
|
+
the file `workspace.secretsFile` names. Never write the key itself in the config.
|
|
36
|
+
- The remote holds one place: `plugin` names the `plugins` entry, and `settings` hold that place's
|
|
37
|
+
Hyperbrowser settings.
|
|
38
|
+
- `use.browser.remote: hyperbrowser` in the config, a suite or a test runs the test there.
|
|
39
|
+
- Install the package at the exact version of agent-qa the project runs:
|
|
40
|
+
`npm install --save-dev --save-exact @vostride/agent-qa-plugin-hyperbrowser@<agent-qa version>`.
|
|
41
|
+
`agent-qa plugins list` prints that line when the package is missing or the version differs.
|
|
42
|
+
|
|
43
|
+
## Settings
|
|
44
|
+
|
|
45
|
+
- `timeoutMinutes` (default `720`, 1 to 720): minutes before Hyperbrowser stops the session. It
|
|
46
|
+
overrides the team default in Hyperbrowser's settings. The plugin never asks for keep-alive, so
|
|
47
|
+
Hyperbrowser also stops a session whose connection drops, and agent-qa stops every session after
|
|
48
|
+
its test. A session whose create was in flight when the job was killed was never connected, so
|
|
49
|
+
it runs until `timeoutMinutes`.
|
|
50
|
+
- `recording` (unset by default): Hyperbrowser's web recording. Unset keeps the account's default,
|
|
51
|
+
which records. `recording: false` turns it off.
|
|
52
|
+
- `stealth`, `solveCaptchas`, `adblock`, `trackers`, `annoyances`, `acceptCookies` (each default
|
|
53
|
+
`false`): Hyperbrowser's stealth mode, CAPTCHA solving, ad, tracker and annoyance blocking and
|
|
54
|
+
automatic acceptance of cookie banners.
|
|
55
|
+
- `proxy` (default `false`): `true`, or `{ country, state, city }` with any of the three. Proxies
|
|
56
|
+
and CAPTCHA solving need a paid Hyperbrowser plan.
|
|
57
|
+
- `screen` (`{ width, height }` in pixels), `region` (for example `us-east` or `europe-west`),
|
|
58
|
+
`extensions` (extension ids): Hyperbrowser's own create fields, sent only when set.
|
|
59
|
+
- `profile`: a Hyperbrowser profile id. It loads read-only: nothing the test does is saved back. It
|
|
60
|
+
needs `context: reuse` on the remote, because the profile lives in the browser's first context:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
- name: hyperbrowser-signed-in
|
|
64
|
+
plugin: hyperbrowser
|
|
65
|
+
context: reuse
|
|
66
|
+
settings:
|
|
67
|
+
profile: <Hyperbrowser profile id>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- `extra`: any other Hyperbrowser request field. It is merged last, so it overrides the settings
|
|
71
|
+
above. Video replay is `extra: { enableVideoWebRecording: true }`, and needs recording on.
|
|
72
|
+
Enterprise proxy servers go here as `proxyServer`, `proxyServerUsername` and
|
|
73
|
+
`proxyServerPassword`; write the password as a `{{secret:<name>}}` reference.
|
|
74
|
+
|
|
75
|
+
## When a remote is refused
|
|
76
|
+
|
|
77
|
+
- `auth`: Hyperbrowser refused the API key. Check the secret's value.
|
|
78
|
+
- `capacity`: Hyperbrowser answered 429. The account has no free sessions, or is rate limited.
|
|
79
|
+
Wait, or lower the run's concurrency.
|
|
80
|
+
- `refused` with `Hyperbrowser answered 403 to POST /api/session: <reason>`: a feature the remote
|
|
81
|
+
turns on is not on the plan, for example `Proxy usage requires a paid plan`. Turn that setting
|
|
82
|
+
off, or change the plan.
|
|
83
|
+
- `refused` with path `settings.profile`: the remote sets `profile` without `context: reuse`. Add
|
|
84
|
+
`context: reuse` to the remote.
|
|
85
|
+
- `refused` with path `settings.timeoutMinutes`: the lifetime is outside 1 to 720. Set a value in
|
|
86
|
+
that range, or leave it out for 720.
|
|
87
|
+
- `refused` naming a browser or platform: Hyperbrowser runs Chromium web tests only. Use
|
|
88
|
+
`use.browser.name: chromium`, or another remote.
|
|
89
|
+
- `refused` at `options.apiUrl`: the endpoint must use `https://` with no user or password, unless
|
|
90
|
+
it points at this machine.
|
|
91
|
+
- `unreachable`: Hyperbrowser could not be reached. Check the network and `apiUrl`.
|
|
92
|
+
|
|
93
|
+
## Recording
|
|
94
|
+
|
|
95
|
+
Hyperbrowser records sessions by default and keeps the replay on its dashboard, behind its login.
|
|
96
|
+
agent-qa's masking does not reach that replay, so a secret a test types may show in it. Set
|
|
97
|
+
`recording: false` on the remote to turn recording off. The session link opens the session's page
|
|
98
|
+
in Hyperbrowser's dashboard. Hyperbrowser's live view URL and the session token let anyone who has
|
|
99
|
+
them watch and drive the session, so agent-qa masks both and never shows them as the link.
|
|
100
|
+
|
|
101
|
+
## Check and describe
|
|
102
|
+
|
|
103
|
+
- `agent-qa doctor` runs the plugin's account check. It reads the active sessions to prove the key,
|
|
104
|
+
reports how many there are, and opens none.
|
|
105
|
+
- `agent-qa plugins describe hyperbrowser --json` lists every option and setting with its type,
|
|
106
|
+
default and description.
|