@e2e-dev/e2b 0.0.0-stage → 0.0.0
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 +202 -0
- package/NOTICE +5 -0
- package/README.md +62 -2
- package/dist/.build.json +1 -0
- package/dist/browsers.d.ts +32 -0
- package/dist/browsers.js +130 -0
- package/dist/client.d.ts +57 -0
- package/dist/client.js +155 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/lease.d.ts +52 -0
- package/dist/lease.js +119 -0
- package/dist/provider.d.ts +23 -0
- package/dist/provider.js +130 -0
- package/dist/tunnel.d.ts +26 -0
- package/dist/tunnel.js +153 -0
- package/dist/web.d.ts +8 -0
- package/dist/web.js +7 -0
- package/package.json +86 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/NOTICE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @e2e-dev/e2b
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run Android and web tests in [E2B](https://e2b.dev) sandboxes. Each worker gets its own
|
|
4
|
+
Android 16 sandbox with your `.apk` installed, and the sandbox is deleted when the run ends.
|
|
5
|
+
No Android SDK or `adb` is needed on your machine. For web tests, see [Browsers](#browsers).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev e2e @e2e-dev/mobile @e2e-dev/e2b e2b
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts title="e2e.config.ts"
|
|
12
|
+
import type { E2EConfig } from 'e2e';
|
|
13
|
+
import { mobile } from '@e2e-dev/mobile';
|
|
14
|
+
import { e2b } from '@e2e-dev/e2b';
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
targets: [
|
|
18
|
+
{
|
|
19
|
+
engine: mobile({ platform: 'android', device: e2b() }),
|
|
20
|
+
app: { bundleId: 'com.example.app', appPath: 'build/app-debug.apk' },
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
workers: 4,
|
|
24
|
+
} satisfies E2EConfig;
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Set `E2B_API_KEY` from the [E2B dashboard](https://e2b.dev/dashboard), and `E2B_DOMAIN`
|
|
28
|
+
for a dedicated E2B cluster. Android on E2B is x86_64 only, without Google Play Services.
|
|
29
|
+
Sandboxes refuse public traffic; tests reach them through a local tunnel on `127.0.0.1`.
|
|
30
|
+
|
|
31
|
+
See the [integration guide](https://e2e.tester.army/docs/integrations/e2b) for every
|
|
32
|
+
option and the limits.
|
|
33
|
+
|
|
34
|
+
## Browsers
|
|
35
|
+
|
|
36
|
+
`web({ browser: e2bBrowsers() })` runs a web target in Chromium in an E2B sandbox: one
|
|
37
|
+
sandbox per worker, or per attempt with `scope: 'attempt'`. Import it from
|
|
38
|
+
`@e2e-dev/e2b/web`, which needs only `@e2e-dev/web`.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install --save-dev e2e @e2e-dev/web @e2e-dev/e2b e2b
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ts title="e2e.config.ts"
|
|
45
|
+
import type { E2EConfig } from 'e2e';
|
|
46
|
+
import { web } from '@e2e-dev/web';
|
|
47
|
+
import { e2bBrowsers } from '@e2e-dev/e2b/web';
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
targets: [{ engine: web({ browser: e2bBrowsers() }), app: { url: 'https://staging.example.com' } }],
|
|
51
|
+
workers: 4,
|
|
52
|
+
} satisfies E2EConfig;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It uses the same `E2B_API_KEY`, and a sandbox template with Chromium that you build once
|
|
56
|
+
from the Playwright image. The browser runs in E2B's cloud, so `app.url` must be reachable
|
|
57
|
+
from the internet. See the
|
|
58
|
+
[integration guide](https://e2e.tester.army/docs/integrations/e2b) for the template, the
|
|
59
|
+
defaults, and the limits.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
Apache-2.0
|
package/dist/.build.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"0.0.0"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Chromium in E2B sandboxes as a `BrowserProvider` for the web engine. */
|
|
2
|
+
import type { BrowserProvider, BrowserProviderScope } from '@e2e-dev/web';
|
|
3
|
+
export interface E2BBrowsersOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Sandbox template with Playwright's Chromium under `/ms-playwright`, such as one built from the Playwright image
|
|
6
|
+
* `@e2e-dev/web` pins. `e2e-chromium` by default.
|
|
7
|
+
*/
|
|
8
|
+
readonly template?: string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* `worker` (default): one browser sandbox per worker slot, leased in
|
|
11
|
+
* `prepare` and released in `finish`. `attempt`: a fresh sandbox per test
|
|
12
|
+
* attempt.
|
|
13
|
+
*/
|
|
14
|
+
readonly scope?: BrowserProviderScope | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* How long a sandbox lives before E2B kills it, in milliseconds. Defaults to
|
|
17
|
+
* one hour: the backstop for a run that died before it could release.
|
|
18
|
+
*/
|
|
19
|
+
readonly timeoutMs?: number | undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* E2B browsers for `web({ browser: e2bBrowsers() })`: Chromium in an E2B
|
|
23
|
+
* sandbox per worker slot, or per attempt with `scope: 'attempt'`. Chromium
|
|
24
|
+
* listens on the sandbox's loopback, and E2B's edge masks each request's
|
|
25
|
+
* `Host` as localhost so DevTools accepts it. The sandbox refuses public
|
|
26
|
+
* traffic: the lease's `cdpEndpoint` is a loopback tunnel on the runner's
|
|
27
|
+
* machine that adds E2B's traffic access token, so it carries no secret.
|
|
28
|
+
* Sandboxes are tagged with the run, target, slot, and attempt, killed at
|
|
29
|
+
* release, swept at the end of the run, and killed by E2B after `timeoutMs`
|
|
30
|
+
* if the run died first.
|
|
31
|
+
*/
|
|
32
|
+
export declare function e2bBrowsers(options?: E2BBrowsersOptions): BrowserProvider;
|
package/dist/browsers.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Chromium in E2B sandboxes as a `BrowserProvider` for the web engine. */
|
|
2
|
+
import { rejectUnknownKeys } from 'e2e/engine';
|
|
3
|
+
import { sandboxClients } from './client.js';
|
|
4
|
+
import { leaseMetadata, messageOf, poll, sandboxLeases, templateOption, timeoutOption, withLog } from './lease.js';
|
|
5
|
+
const OPTION_KEYS = Object.keys({
|
|
6
|
+
template: true,
|
|
7
|
+
scope: true,
|
|
8
|
+
timeoutMs: true,
|
|
9
|
+
});
|
|
10
|
+
const DEFAULT_TEMPLATE = 'e2e-chromium';
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 60 * 60_000;
|
|
12
|
+
/** Chromium's DevTools port, on the sandbox's loopback only. */
|
|
13
|
+
const CHROMIUM_CDP_PORT = 9222;
|
|
14
|
+
/**
|
|
15
|
+
* Chromium refuses a DevTools request whose `Host` is neither an IP address
|
|
16
|
+
* nor localhost, which every request through E2B's public host would be, so
|
|
17
|
+
* E2B's edge rewrites `Host` to this on the way into the sandbox. `${PORT}`
|
|
18
|
+
* is E2B's placeholder for the port, not a template literal.
|
|
19
|
+
*/
|
|
20
|
+
const MASKED_HOST = 'localhost:${PORT}';
|
|
21
|
+
/** Where each browser saves downloads on its own disk, for the engine to read back through the sandbox filesystem. */
|
|
22
|
+
const DOWNLOADS_DIR = '/tmp/e2e-downloads';
|
|
23
|
+
/** Chromium's output, read into the error when it never answers. */
|
|
24
|
+
const CHROMIUM_LOG = '/tmp/e2e-chromium.log';
|
|
25
|
+
/** Bounds Chromium's start and its first answer from outside, together. */
|
|
26
|
+
const START_TIMEOUT_MS = 60_000;
|
|
27
|
+
/**
|
|
28
|
+
* Finds the template's Playwright Chromium and prints its path, run in the foreground so a
|
|
29
|
+
* template without it fails the lease at once, with this message, instead of at the start timeout.
|
|
30
|
+
*/
|
|
31
|
+
const FIND_CHROMIUM = [
|
|
32
|
+
`mkdir -p ${DOWNLOADS_DIR}`,
|
|
33
|
+
'chrome=$(find /ms-playwright -maxdepth 3 -type f -name chrome 2>/dev/null | head -n 1)',
|
|
34
|
+
'[ -x "$chrome" ] || { echo "Playwright Chromium is missing from the sandbox template: no /ms-playwright/*/chrome-linux*/chrome" >&2; exit 1; }',
|
|
35
|
+
'printf %s "$chrome"',
|
|
36
|
+
].join('\n');
|
|
37
|
+
/** A path `find` printed for Chromium, as the only shape that reaches the start command. */
|
|
38
|
+
const CHROMIUM_PATH = /^\/ms-playwright\/[\w./-]+$/;
|
|
39
|
+
/** Starts Chromium at `chrome` headless, with DevTools on loopback. */
|
|
40
|
+
function startChromium(chrome) {
|
|
41
|
+
if (!CHROMIUM_PATH.test(chrome))
|
|
42
|
+
throw new Error(`unexpected Chromium path ${JSON.stringify(chrome)} in the sandbox template`);
|
|
43
|
+
// A history restore from Chromium's back/forward cache emits no load event
|
|
44
|
+
// over CDP, so Playwright goBack({ waitUntil: 'load' }) otherwise hangs.
|
|
45
|
+
return `exec ${chrome} --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-features=BackForwardCache --remote-debugging-port=${CHROMIUM_CDP_PORT} --user-data-dir=/tmp/e2e-chromium about:blank > ${CHROMIUM_LOG} 2>&1`;
|
|
46
|
+
}
|
|
47
|
+
/** The browser WebSocket URL in a `/json/version` answer, or `undefined`. */
|
|
48
|
+
function debuggerUrl(body) {
|
|
49
|
+
return typeof body === 'object' && body !== null && 'webSocketDebuggerUrl' in body ? body.webSocketDebuggerUrl : undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Polls Chromium through the tunnel until it answers, and resolves to its
|
|
53
|
+
* browser WebSocket endpoint on the tunnel. Chromium reports the endpoint
|
|
54
|
+
* under the masked `localhost` host, so only its path is kept: the endpoint a
|
|
55
|
+
* lease carries is a bare loopback URL.
|
|
56
|
+
*/
|
|
57
|
+
function browserEndpoint(tunnel, signal) {
|
|
58
|
+
return poll('Chromium did not answer through the sandbox tunnel', signal, async (attempt) => {
|
|
59
|
+
const response = await fetch(`${tunnel.url}/json/version`, { signal: attempt });
|
|
60
|
+
if (!response.ok)
|
|
61
|
+
throw new Error(`HTTP ${response.status}`);
|
|
62
|
+
const endpoint = debuggerUrl(await response.json());
|
|
63
|
+
const path = typeof endpoint === 'string' && URL.canParse(endpoint) ? new URL(endpoint).pathname : '';
|
|
64
|
+
if (!path.startsWith('/devtools/browser/'))
|
|
65
|
+
throw new Error('no WebSocket endpoint in /json/version');
|
|
66
|
+
return `${tunnel.url.replace(/^http:/, 'ws:')}${path}`;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* E2B browsers for `web({ browser: e2bBrowsers() })`: Chromium in an E2B
|
|
71
|
+
* sandbox per worker slot, or per attempt with `scope: 'attempt'`. Chromium
|
|
72
|
+
* listens on the sandbox's loopback, and E2B's edge masks each request's
|
|
73
|
+
* `Host` as localhost so DevTools accepts it. The sandbox refuses public
|
|
74
|
+
* traffic: the lease's `cdpEndpoint` is a loopback tunnel on the runner's
|
|
75
|
+
* machine that adds E2B's traffic access token, so it carries no secret.
|
|
76
|
+
* Sandboxes are tagged with the run, target, slot, and attempt, killed at
|
|
77
|
+
* release, swept at the end of the run, and killed by E2B after `timeoutMs`
|
|
78
|
+
* if the run died first.
|
|
79
|
+
*/
|
|
80
|
+
export function e2bBrowsers(options = {}) {
|
|
81
|
+
rejectUnknownKeys('e2bBrowsers()', options, OPTION_KEYS);
|
|
82
|
+
const template = templateOption('e2bBrowsers()', options.template, DEFAULT_TEMPLATE);
|
|
83
|
+
const timeoutMs = timeoutOption('e2bBrowsers()', options.timeoutMs, DEFAULT_TIMEOUT_MS);
|
|
84
|
+
const clientFor = sandboxClients();
|
|
85
|
+
const leases = sandboxLeases(clientFor);
|
|
86
|
+
/** Starts Chromium and resolves to the endpoint a worker attaches to, through the lease's tunnel. */
|
|
87
|
+
async function ready(sandbox, tunnel, request) {
|
|
88
|
+
request.log(`browser sandbox ${sandbox.sandboxId}`);
|
|
89
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(START_TIMEOUT_MS)]);
|
|
90
|
+
const chrome = await sandbox.run(FIND_CHROMIUM, { timeoutMs: START_TIMEOUT_MS, signal });
|
|
91
|
+
await sandbox.start(startChromium(chrome.trim()), { signal });
|
|
92
|
+
const cdpEndpoint = await browserEndpoint(tunnel, signal).catch(async (cause) => {
|
|
93
|
+
throw await withLog(sandbox, CHROMIUM_LOG, cause);
|
|
94
|
+
});
|
|
95
|
+
return { id: sandbox.sandboxId, cdpEndpoint };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
name: 'e2b',
|
|
99
|
+
...(options.scope === undefined ? {} : { scope: options.scope }),
|
|
100
|
+
async acquire(request) {
|
|
101
|
+
const metadata = { ...leaseMetadata(request), ...(request.attemptId === undefined ? {} : { e2e_attempt: request.attemptId }) };
|
|
102
|
+
return leases.acquire(request, { template, timeoutMs, metadata, maskRequestHost: MASKED_HOST, port: CHROMIUM_CDP_PORT }, (sandbox, tunnel) => ready(sandbox, tunnel, request));
|
|
103
|
+
},
|
|
104
|
+
release: (lease, context) => leases.release(lease.id, context),
|
|
105
|
+
async sweep(context) {
|
|
106
|
+
const client = clientFor(context.env);
|
|
107
|
+
const open = await client.list({ e2e_run: context.runId, e2e_target: context.targetName }, context.signal);
|
|
108
|
+
const settled = await Promise.allSettled(open.map((id) => client.kill(id, context.signal)));
|
|
109
|
+
const killed = [];
|
|
110
|
+
const failed = [];
|
|
111
|
+
settled.forEach((result, index) => {
|
|
112
|
+
const id = open[index];
|
|
113
|
+
if (result.status === 'rejected') {
|
|
114
|
+
failed.push(`${id} (${messageOf(result.reason)})`);
|
|
115
|
+
}
|
|
116
|
+
else if (result.value) {
|
|
117
|
+
killed.push(id);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
if (failed.length > 0) {
|
|
121
|
+
throw new Error(`killed ${killed.length === 0 ? 'none' : killed.join(', ')}; could not kill ${failed.join(', ')}, so E2B ends them after timeoutMs`);
|
|
122
|
+
}
|
|
123
|
+
return killed;
|
|
124
|
+
},
|
|
125
|
+
downloads: {
|
|
126
|
+
dir: DOWNLOADS_DIR,
|
|
127
|
+
read: (lease, file, context) => clientFor(context.env).readFile(lease.id, file, context.signal),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the E2B SDK both providers use, loaded on first use so a
|
|
3
|
+
* config load never pays for it, and the connection the run's environment
|
|
4
|
+
* names.
|
|
5
|
+
*/
|
|
6
|
+
export interface E2BCreateParams {
|
|
7
|
+
readonly template: string;
|
|
8
|
+
readonly timeoutMs: number;
|
|
9
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
10
|
+
/**
|
|
11
|
+
* The `Host` E2B's edge puts on every request it forwards into the sandbox,
|
|
12
|
+
* with `${PORT}` standing for the port, such as `localhost:${PORT}`. Unset,
|
|
13
|
+
* a request arrives with the public host it was sent to.
|
|
14
|
+
*/
|
|
15
|
+
readonly maskRequestHost?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
interface StartOptions {
|
|
18
|
+
/** Environment variables for the command only, such as a token kept out of the command text E2B receives. */
|
|
19
|
+
readonly envs?: Record<string, string> | undefined;
|
|
20
|
+
readonly signal: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
/** One running sandbox, as much of it as the providers drive. */
|
|
23
|
+
export interface E2BSandbox {
|
|
24
|
+
readonly sandboxId: string;
|
|
25
|
+
/** The token E2B wants on every request to a sandbox port: sandboxes refuse public traffic. */
|
|
26
|
+
readonly trafficToken: string;
|
|
27
|
+
/** Runs a shell command to completion and resolves to its stdout; rejects when it exits non-zero, with its output in the message. */
|
|
28
|
+
run(command: string, options: {
|
|
29
|
+
readonly timeoutMs: number;
|
|
30
|
+
readonly signal: AbortSignal;
|
|
31
|
+
}): Promise<string>;
|
|
32
|
+
/** Starts a shell command and leaves it running. */
|
|
33
|
+
start(command: string, options: StartOptions): Promise<void>;
|
|
34
|
+
/** The public host a port inside the sandbox answers on, without the scheme. */
|
|
35
|
+
host(port: number): string;
|
|
36
|
+
}
|
|
37
|
+
export interface E2BSandboxes {
|
|
38
|
+
/**
|
|
39
|
+
* Creates a sandbox under a budget of its own: a creation request cut off by
|
|
40
|
+
* the run's interrupt could still start a sandbox whose id never arrives, so
|
|
41
|
+
* the caller checks its own signal once the id is in hand.
|
|
42
|
+
*/
|
|
43
|
+
create(params: E2BCreateParams): Promise<E2BSandbox>;
|
|
44
|
+
/** The bytes of one file on the disk of a sandbox another process may have created. */
|
|
45
|
+
readFile(sandboxId: string, file: string, signal: AbortSignal): Promise<Uint8Array>;
|
|
46
|
+
/** Kills a sandbox; one E2B no longer knows counts as killed. Resolves to whether E2B still knew it. */
|
|
47
|
+
kill(sandboxId: string, signal: AbortSignal): Promise<boolean>;
|
|
48
|
+
/** The ids of the running sandboxes carrying every one of `metadata`. */
|
|
49
|
+
list(metadata: Readonly<Record<string, string>>, signal: AbortSignal): Promise<string[]>;
|
|
50
|
+
}
|
|
51
|
+
/** The last `OUTPUT_TAIL_CHARS` of a command's output. */
|
|
52
|
+
export declare function tail(text: string | undefined): string;
|
|
53
|
+
/** The SDK client for a run's environment. */
|
|
54
|
+
export type ClientFor = (env: Readonly<Record<string, string | undefined>>) => E2BSandboxes;
|
|
55
|
+
/** One SDK client per connection, shared by every request a provider serves. */
|
|
56
|
+
export declare function sandboxClients(): ClientFor;
|
|
57
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the E2B SDK both providers use, loaded on first use so a
|
|
3
|
+
* config load never pays for it, and the connection the run's environment
|
|
4
|
+
* names.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { ConfigurationError } from 'e2e/engine';
|
|
9
|
+
/** Bounds one creation request: E2B answers in seconds, so a minute means it is not coming. */
|
|
10
|
+
const CREATE_TIMEOUT_MS = 60_000;
|
|
11
|
+
/**
|
|
12
|
+
* Appended to the `User-Agent` of every E2B request, so E2B can tell this integration's traffic apart.
|
|
13
|
+
* The version is this package's, not e2e's.
|
|
14
|
+
*/
|
|
15
|
+
const INTEGRATION = `e2e-e2b/${createRequire(import.meta.url)('../package.json').version}`;
|
|
16
|
+
/** How much of a failed command's output its error keeps: enough to name what failed without flooding the run's error line. */
|
|
17
|
+
const OUTPUT_TAIL_CHARS = 600;
|
|
18
|
+
/** The last `OUTPUT_TAIL_CHARS` of a command's output. */
|
|
19
|
+
export function tail(text) {
|
|
20
|
+
const trimmed = (text ?? '').trim();
|
|
21
|
+
return trimmed.length > OUTPUT_TAIL_CHARS ? `...${trimmed.slice(-OUTPUT_TAIL_CHARS)}` : trimmed;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A signal that follows `signal` only until `settled` does. The SDK ties a
|
|
25
|
+
* command's signal to its output stream for the command's whole life, so a
|
|
26
|
+
* long-running command gets one that can cancel its start but never its stream.
|
|
27
|
+
*/
|
|
28
|
+
async function untilSettled(signal, settled) {
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
const starting = new AbortController();
|
|
31
|
+
const onAbort = () => starting.abort(signal.reason);
|
|
32
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
33
|
+
try {
|
|
34
|
+
return await settled(starting.signal);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
signal.removeEventListener('abort', onAbort);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** E2B sandboxes for one connection, through the SDK. */
|
|
41
|
+
function e2bSandboxes(connection) {
|
|
42
|
+
const sdk = import('e2b').then((module) => {
|
|
43
|
+
// Read when the SDK builds a connection config, so it runs before the first request.
|
|
44
|
+
module.ConnectionConfig.setIntegration(INTEGRATION);
|
|
45
|
+
return module;
|
|
46
|
+
});
|
|
47
|
+
const { sandboxUrl, ...rest } = connection;
|
|
48
|
+
const opts = { ...rest, ...(sandboxUrl === undefined ? {} : { sandboxUrl }), debug: false };
|
|
49
|
+
async function wrap(sandbox) {
|
|
50
|
+
const { CommandExitError } = await sdk;
|
|
51
|
+
const trafficToken = sandbox.trafficAccessToken;
|
|
52
|
+
if (trafficToken === undefined || trafficToken === '') {
|
|
53
|
+
throw new Error(`E2B created sandbox ${sandbox.sandboxId} without a traffic access token`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
sandboxId: sandbox.sandboxId,
|
|
57
|
+
trafficToken,
|
|
58
|
+
async run(command, { timeoutMs, signal }) {
|
|
59
|
+
try {
|
|
60
|
+
// Aborting ends only the SDK's stream; the process dies with the sandbox the failed lease kills.
|
|
61
|
+
const result = await sandbox.commands.run(command, { timeoutMs, signal });
|
|
62
|
+
return result.stdout;
|
|
63
|
+
}
|
|
64
|
+
catch (cause) {
|
|
65
|
+
if (signal.aborted)
|
|
66
|
+
throw signal.reason;
|
|
67
|
+
if (cause instanceof CommandExitError)
|
|
68
|
+
throw new Error(`exited ${cause.exitCode}: ${tail(cause.stderr) || tail(cause.stdout)}`, { cause });
|
|
69
|
+
throw cause;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async start(command, { envs, signal }) {
|
|
73
|
+
const handle = await untilSettled(signal, (starting) => sandbox.commands.run(command, { background: true, timeoutMs: 0, signal: starting, ...(envs === undefined ? {} : { envs }) }));
|
|
74
|
+
// The process keeps running; only the SDK's output stream for it closes.
|
|
75
|
+
await handle.disconnect();
|
|
76
|
+
},
|
|
77
|
+
host: (port) => sandbox.getHost(port),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
async create(params) {
|
|
82
|
+
const { Sandbox } = await sdk;
|
|
83
|
+
const sandbox = await Sandbox.create(params.template, {
|
|
84
|
+
...opts,
|
|
85
|
+
timeoutMs: params.timeoutMs,
|
|
86
|
+
metadata: { ...params.metadata },
|
|
87
|
+
network: { allowPublicTraffic: false, ...(params.maskRequestHost === undefined ? {} : { maskRequestHost: params.maskRequestHost }) },
|
|
88
|
+
// The SDK retries a failed create; the key makes E2B answer a retry with the same sandbox.
|
|
89
|
+
idempotencyKey: `e2e-${randomUUID()}`,
|
|
90
|
+
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
return await wrap(sandbox);
|
|
94
|
+
}
|
|
95
|
+
catch (cause) {
|
|
96
|
+
await sandbox.kill().catch(() => { });
|
|
97
|
+
throw cause;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
async readFile(sandboxId, file, signal) {
|
|
101
|
+
const { Sandbox } = await sdk;
|
|
102
|
+
const sandbox = await Sandbox.connect(sandboxId, { ...opts, signal });
|
|
103
|
+
return sandbox.files.read(file, { format: 'bytes', signal });
|
|
104
|
+
},
|
|
105
|
+
async kill(sandboxId, signal) {
|
|
106
|
+
const { Sandbox } = await sdk;
|
|
107
|
+
return Sandbox.kill(sandboxId, { ...opts, signal });
|
|
108
|
+
},
|
|
109
|
+
async list(metadata, signal) {
|
|
110
|
+
const { Sandbox } = await sdk;
|
|
111
|
+
const paginator = Sandbox.list({ ...opts, query: { metadata: { ...metadata }, state: ['running'] } });
|
|
112
|
+
const ids = [];
|
|
113
|
+
while (paginator.hasNext) {
|
|
114
|
+
signal.throwIfAborted();
|
|
115
|
+
for (const info of await paginator.nextItems({ signal }))
|
|
116
|
+
ids.push(info.sandboxId);
|
|
117
|
+
}
|
|
118
|
+
return ids;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** A non-empty variable from the run's environment, trimmed, or `undefined`. */
|
|
123
|
+
function envValue(env, name) {
|
|
124
|
+
const value = env[name]?.trim();
|
|
125
|
+
return value === undefined || value === '' ? undefined : value;
|
|
126
|
+
}
|
|
127
|
+
/** The connection the run's environment names, never `process.env`. */
|
|
128
|
+
function connectionFor(env) {
|
|
129
|
+
const apiKey = envValue(env, 'E2B_API_KEY');
|
|
130
|
+
if (apiKey === undefined) {
|
|
131
|
+
throw new ConfigurationError('INVALID_CONFIG', '@e2e-dev/e2b requires E2B_API_KEY in the run environment');
|
|
132
|
+
}
|
|
133
|
+
const sandboxUrl = envValue(env, 'E2B_SANDBOX_URL');
|
|
134
|
+
// The SDK reads the runner's `E2B_SANDBOX_URL` when given none.
|
|
135
|
+
const ambient = envValue(process.env, 'E2B_SANDBOX_URL');
|
|
136
|
+
if (ambient !== undefined && ambient !== sandboxUrl) {
|
|
137
|
+
throw new ConfigurationError('INVALID_CONFIG', "@e2e-dev/e2b: unset E2B_SANDBOX_URL in the runner's environment, or set the same value in the run environment; the E2B SDK reads it whatever the run environment says");
|
|
138
|
+
}
|
|
139
|
+
const domain = envValue(env, 'E2B_DOMAIN') ?? 'e2b.app';
|
|
140
|
+
return { apiKey, domain, apiUrl: envValue(env, 'E2B_API_URL') ?? `https://api.${domain}`, ...(sandboxUrl === undefined ? {} : { sandboxUrl }) };
|
|
141
|
+
}
|
|
142
|
+
/** One SDK client per connection, shared by every request a provider serves. */
|
|
143
|
+
export function sandboxClients() {
|
|
144
|
+
const clients = new Map();
|
|
145
|
+
return (env) => {
|
|
146
|
+
const connection = connectionFor(env);
|
|
147
|
+
const key = `${connection.apiUrl}\n${connection.sandboxUrl ?? ''}\n${connection.domain}\n${connection.apiKey}`;
|
|
148
|
+
let client = clients.get(key);
|
|
149
|
+
if (client === undefined) {
|
|
150
|
+
client = e2bSandboxes(connection);
|
|
151
|
+
clients.set(key, client);
|
|
152
|
+
}
|
|
153
|
+
return client;
|
|
154
|
+
};
|
|
155
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@e2e-dev/e2b` public surface: `e2b()`, a device provider that runs each
|
|
3
|
+
* worker slot's Android on an E2B `android-16` sandbox for `@e2e-dev/mobile`.
|
|
4
|
+
* `e2bBrowsers()`, for Chromium in E2B sandboxes on `@e2e-dev/web`, is
|
|
5
|
+
* exported from `@e2e-dev/e2b/web`.
|
|
6
|
+
*/
|
|
7
|
+
export { e2b } from './provider.ts';
|
|
8
|
+
export type { E2BOptions } from './provider.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@e2e-dev/e2b` public surface: `e2b()`, a device provider that runs each
|
|
3
|
+
* worker slot's Android on an E2B `android-16` sandbox for `@e2e-dev/mobile`.
|
|
4
|
+
* `e2bBrowsers()`, for Chromium in E2B sandboxes on `@e2e-dev/web`, is
|
|
5
|
+
* exported from `@e2e-dev/e2b/web`.
|
|
6
|
+
*/
|
|
7
|
+
export { e2b } from './provider.js';
|
package/dist/lease.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lease lifecycle both providers share: create a sandbox, open the tunnel
|
|
3
|
+
* to its port, wait until the provider's service is ready, and on any failure
|
|
4
|
+
* close the tunnel and kill the sandbox; plus the option checks and the
|
|
5
|
+
* polling and log reading the ready steps use.
|
|
6
|
+
*/
|
|
7
|
+
import { type ClientFor, type E2BCreateParams, type E2BSandbox } from './client.ts';
|
|
8
|
+
import { type Tunnel } from './tunnel.ts';
|
|
9
|
+
/** What a lease needs from the engine's request: the run's environment and its interrupt. */
|
|
10
|
+
interface LeaseRequest {
|
|
11
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
/** A sandbox to create, and the port inside it the lease's tunnel reaches. */
|
|
15
|
+
export interface LeaseParams extends E2BCreateParams {
|
|
16
|
+
readonly port: number;
|
|
17
|
+
}
|
|
18
|
+
/** The message of a thrown value, for an error line. */
|
|
19
|
+
export declare function messageOf(cause: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* Calls `probe` every `POLL_INTERVAL_MS`, each call bounded to
|
|
22
|
+
* `PROBE_TIMEOUT_MS`, until it resolves, and resolves to its value. When
|
|
23
|
+
* `signal` ends first, fails with `what` and the last probe's error.
|
|
24
|
+
*/
|
|
25
|
+
export declare function poll<T>(what: string, signal: AbortSignal, probe: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
26
|
+
/**
|
|
27
|
+
* `cause` with the last lines of `log` in the sandbox, for a service that never answered: the log says
|
|
28
|
+
* why. `secret` is cut from them. Reading the log is best-effort and bounded on its own clock.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withLog(sandbox: E2BSandbox, log: string, cause: unknown, secret?: string): Promise<Error>;
|
|
31
|
+
/** A template option: a non-empty name, or the provider's default when absent. */
|
|
32
|
+
export declare function templateOption(factory: string, template: unknown, fallback: string): string;
|
|
33
|
+
/** A sandbox lifetime option: a positive integer of milliseconds, or the provider's default when absent. */
|
|
34
|
+
export declare function timeoutOption(factory: string, timeoutMs: unknown, fallback: number): number;
|
|
35
|
+
/** The tags every sandbox carries, so a sweep or a person can find a run's sandboxes. */
|
|
36
|
+
export declare function leaseMetadata(request: {
|
|
37
|
+
readonly runId: string;
|
|
38
|
+
readonly targetName: string;
|
|
39
|
+
readonly slot: number;
|
|
40
|
+
}): Record<string, string>;
|
|
41
|
+
/**
|
|
42
|
+
* The leases one provider holds. `acquire` creates a sandbox, opens the tunnel
|
|
43
|
+
* to `params.port`, and hands both to `ready`, which resolves to the lease. Any
|
|
44
|
+
* failure after the id is known closes the tunnel and kills the sandbox,
|
|
45
|
+
* including a run interrupted while the creation request was in flight.
|
|
46
|
+
* `release` kills the lease's sandbox and closes its tunnel.
|
|
47
|
+
*/
|
|
48
|
+
export declare function sandboxLeases(clientFor: ClientFor): {
|
|
49
|
+
acquire<T>(request: LeaseRequest, params: LeaseParams, ready: (sandbox: E2BSandbox, tunnel: Tunnel) => Promise<T>): Promise<T>;
|
|
50
|
+
release(id: string, context: LeaseRequest): Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
export {};
|
package/dist/lease.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lease lifecycle both providers share: create a sandbox, open the tunnel
|
|
3
|
+
* to its port, wait until the provider's service is ready, and on any failure
|
|
4
|
+
* close the tunnel and kill the sandbox; plus the option checks and the
|
|
5
|
+
* polling and log reading the ready steps use.
|
|
6
|
+
*/
|
|
7
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
8
|
+
import { ConfigurationError } from 'e2e/engine';
|
|
9
|
+
import { tail } from './client.js';
|
|
10
|
+
import { openTunnel } from './tunnel.js';
|
|
11
|
+
/** Bounds the kill of a sandbox whose lease failed. */
|
|
12
|
+
const KILL_TIMEOUT_MS = 30_000;
|
|
13
|
+
/** How often `poll` asks, and how long one answer may take. */
|
|
14
|
+
const POLL_INTERVAL_MS = 500;
|
|
15
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
16
|
+
/** Bounds the read of a log for an error message. */
|
|
17
|
+
const LOG_TIMEOUT_MS = 10_000;
|
|
18
|
+
/** The message of a thrown value, for an error line. */
|
|
19
|
+
export function messageOf(cause) {
|
|
20
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Calls `probe` every `POLL_INTERVAL_MS`, each call bounded to
|
|
24
|
+
* `PROBE_TIMEOUT_MS`, until it resolves, and resolves to its value. When
|
|
25
|
+
* `signal` ends first, fails with `what` and the last probe's error.
|
|
26
|
+
*/
|
|
27
|
+
export async function poll(what, signal, probe) {
|
|
28
|
+
let last = 'no answer';
|
|
29
|
+
while (true) {
|
|
30
|
+
if (signal.aborted)
|
|
31
|
+
throw new Error(`${what}: ${last}`, { cause: signal.reason });
|
|
32
|
+
try {
|
|
33
|
+
return await probe(AbortSignal.any([signal, AbortSignal.timeout(PROBE_TIMEOUT_MS)]));
|
|
34
|
+
}
|
|
35
|
+
catch (cause) {
|
|
36
|
+
if (!signal.aborted)
|
|
37
|
+
last = messageOf(cause);
|
|
38
|
+
}
|
|
39
|
+
await sleep(POLL_INTERVAL_MS, undefined, { signal }).catch(() => { });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `cause` with the last lines of `log` in the sandbox, for a service that never answered: the log says
|
|
44
|
+
* why. `secret` is cut from them. Reading the log is best-effort and bounded on its own clock.
|
|
45
|
+
*/
|
|
46
|
+
export async function withLog(sandbox, log, cause, secret) {
|
|
47
|
+
const read = await sandbox.run(`tail -n 20 ${log} 2>/dev/null || true`, { timeoutMs: LOG_TIMEOUT_MS, signal: AbortSignal.timeout(LOG_TIMEOUT_MS) }).catch(() => '');
|
|
48
|
+
const lines = tail(secret === undefined ? read : read.replaceAll(secret, '<redacted>'));
|
|
49
|
+
if (lines === '')
|
|
50
|
+
return cause instanceof Error ? cause : new Error(messageOf(cause));
|
|
51
|
+
return new Error(`${messageOf(cause)}; ${log}: ${lines}`, { cause });
|
|
52
|
+
}
|
|
53
|
+
/** A template option: a non-empty name, or the provider's default when absent. */
|
|
54
|
+
export function templateOption(factory, template, fallback) {
|
|
55
|
+
if (template === undefined)
|
|
56
|
+
return fallback;
|
|
57
|
+
if (typeof template !== 'string' || template.trim() === '') {
|
|
58
|
+
throw new ConfigurationError('INVALID_CONFIG', `${factory}: template must be a non-empty template name`);
|
|
59
|
+
}
|
|
60
|
+
return template;
|
|
61
|
+
}
|
|
62
|
+
/** A sandbox lifetime option: a positive integer of milliseconds, or the provider's default when absent. */
|
|
63
|
+
export function timeoutOption(factory, timeoutMs, fallback) {
|
|
64
|
+
if (timeoutMs === undefined)
|
|
65
|
+
return fallback;
|
|
66
|
+
if (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
|
67
|
+
throw new ConfigurationError('INVALID_CONFIG', `${factory}: timeoutMs must be a positive integer of milliseconds`);
|
|
68
|
+
}
|
|
69
|
+
return timeoutMs;
|
|
70
|
+
}
|
|
71
|
+
/** The tags every sandbox carries, so a sweep or a person can find a run's sandboxes. */
|
|
72
|
+
export function leaseMetadata(request) {
|
|
73
|
+
return { e2e_run: request.runId, e2e_target: request.targetName, e2e_slot: String(request.slot) };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The leases one provider holds. `acquire` creates a sandbox, opens the tunnel
|
|
77
|
+
* to `params.port`, and hands both to `ready`, which resolves to the lease. Any
|
|
78
|
+
* failure after the id is known closes the tunnel and kills the sandbox,
|
|
79
|
+
* including a run interrupted while the creation request was in flight.
|
|
80
|
+
* `release` kills the lease's sandbox and closes its tunnel.
|
|
81
|
+
*/
|
|
82
|
+
export function sandboxLeases(clientFor) {
|
|
83
|
+
const tunnels = new Map();
|
|
84
|
+
return {
|
|
85
|
+
async acquire(request, params, ready) {
|
|
86
|
+
const client = clientFor(request.env);
|
|
87
|
+
const { port, ...create } = params;
|
|
88
|
+
request.signal.throwIfAborted();
|
|
89
|
+
const sandbox = await client.create(create);
|
|
90
|
+
let tunnel;
|
|
91
|
+
try {
|
|
92
|
+
request.signal.throwIfAborted();
|
|
93
|
+
tunnel = await openTunnel({ host: sandbox.host(port), trafficToken: sandbox.trafficToken });
|
|
94
|
+
const lease = await ready(sandbox, tunnel);
|
|
95
|
+
tunnels.set(sandbox.sandboxId, tunnel);
|
|
96
|
+
return lease;
|
|
97
|
+
}
|
|
98
|
+
catch (cause) {
|
|
99
|
+
await tunnel?.close();
|
|
100
|
+
try {
|
|
101
|
+
await client.kill(sandbox.sandboxId, AbortSignal.timeout(KILL_TIMEOUT_MS));
|
|
102
|
+
}
|
|
103
|
+
catch (cleanup) {
|
|
104
|
+
throw new AggregateError([cause, cleanup], `E2B lease failed: ${messageOf(cause)}; killing sandbox ${sandbox.sandboxId} also failed, so E2B ends it after timeoutMs: ${messageOf(cleanup)}`, { cause: cleanup });
|
|
105
|
+
}
|
|
106
|
+
throw cause;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
async release(id, context) {
|
|
110
|
+
try {
|
|
111
|
+
await clientFor(context.env).kill(id, context.signal);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await tunnels.get(id)?.close();
|
|
115
|
+
tunnels.delete(id);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** E2B's `android-16` sandboxes as a `DeviceProvider` for the mobile engine. */
|
|
2
|
+
import type { DeviceProvider } from '@e2e-dev/mobile';
|
|
3
|
+
export interface E2BOptions {
|
|
4
|
+
/** Sandbox template that boots Android, `android-16` by default. */
|
|
5
|
+
readonly template?: string | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* How long a sandbox lives before E2B kills it, in milliseconds. Defaults to
|
|
8
|
+
* one hour: the backstop for a run that died before it could release.
|
|
9
|
+
*/
|
|
10
|
+
readonly timeoutMs?: number | undefined;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* E2B Android for `mobile({ platform: 'android', device: e2b() })`: one
|
|
14
|
+
* `android-16` sandbox per worker slot. Each sandbox runs the engine's
|
|
15
|
+
* agent-device behind `agent-device proxy`, with a bearer token minted per
|
|
16
|
+
* lease, so the runner needs no adb of its own. The sandbox refuses public
|
|
17
|
+
* traffic: the lease's daemon is a loopback tunnel on the runner's machine
|
|
18
|
+
* that adds E2B's traffic access token. A target's `app.appPath` (an `.apk`)
|
|
19
|
+
* is installed through that daemon. Sandboxes are tagged
|
|
20
|
+
* with the run, target, and slot, killed at release, and killed by E2B after
|
|
21
|
+
* `timeoutMs` if the run died first.
|
|
22
|
+
*/
|
|
23
|
+
export declare function e2b(options?: E2BOptions): DeviceProvider;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** E2B's `android-16` sandboxes as a `DeviceProvider` for the mobile engine. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { createAgentDeviceClient } from 'agent-device';
|
|
4
|
+
import { ConfigurationError, rejectUnknownKeys } from 'e2e/engine';
|
|
5
|
+
import { sandboxClients } from './client.js';
|
|
6
|
+
import { leaseMetadata, poll, sandboxLeases, templateOption, timeoutOption, withLog } from './lease.js';
|
|
7
|
+
const OPTION_KEYS = Object.keys({
|
|
8
|
+
template: true,
|
|
9
|
+
timeoutMs: true,
|
|
10
|
+
});
|
|
11
|
+
const DEFAULT_TEMPLATE = 'android-16';
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 60 * 60_000;
|
|
13
|
+
/** The adb serial the template's Android answers on; the same device is also listed as `emulator-5554`. */
|
|
14
|
+
const SERIAL = '127.0.0.1:5555';
|
|
15
|
+
/** Port the agent-device proxy listens on inside the sandbox. */
|
|
16
|
+
const PROXY_PORT = 4310;
|
|
17
|
+
/** The proxy's errors, read into the error when it never answers. */
|
|
18
|
+
const PROXY_LOG = '/tmp/agent-device-proxy.log';
|
|
19
|
+
/** The template ships no Node; agent-device needs one. */
|
|
20
|
+
const NODE_VERSION = '24.11.1';
|
|
21
|
+
/** The SHA-256 nodejs.org publishes for that version's linux-x64 tarball (`SHASUMS256.txt`). */
|
|
22
|
+
const NODE_SHA256 = '58a5ff5cc8f2200e458bea22e329d5c1994aa1b111d499ca46ec2411d58239ca';
|
|
23
|
+
/** Bounds the in-sandbox setup, Android's boot, the proxy's first answer, and the build's install, together. */
|
|
24
|
+
const SETUP_TIMEOUT_MS = 300_000;
|
|
25
|
+
/** A plain semver version: the only shape of `agentDeviceVersion` that reaches a shell. */
|
|
26
|
+
const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
27
|
+
/**
|
|
28
|
+
* Installs Node and the engine's agent-device in the sandbox and waits for
|
|
29
|
+
* Android to finish booting. It runs on every sandbox because it cannot be
|
|
30
|
+
* baked into a template: custom templates built on `android-16` do not boot
|
|
31
|
+
* Android.
|
|
32
|
+
*/
|
|
33
|
+
function setupScript(agentDeviceVersion) {
|
|
34
|
+
return [
|
|
35
|
+
'set -eu',
|
|
36
|
+
// One command per line: under `set -e` a failed step inside an `&&` chain would not stop the script.
|
|
37
|
+
'if ! command -v node >/dev/null; then',
|
|
38
|
+
` curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz -o /tmp/node.tar.gz`,
|
|
39
|
+
` echo "${NODE_SHA256} /tmp/node.tar.gz" | sha256sum -c --quiet -`,
|
|
40
|
+
' sudo tar -xzf /tmp/node.tar.gz -C /usr/local --strip-components=1',
|
|
41
|
+
'fi',
|
|
42
|
+
`sudo npm install --global --no-fund --no-audit --loglevel=error agent-device@${agentDeviceVersion}`,
|
|
43
|
+
`adb connect ${SERIAL} >/dev/null`,
|
|
44
|
+
`until [ "$(adb -s ${SERIAL} shell getprop sys.boot_completed | tr -d '\\r')" = 1 ]; do sleep 1; done`,
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Installs the target's `.apk` through the lease's own daemon, resolving to
|
|
49
|
+
* the package agent-device reports: the client uploads the build to the
|
|
50
|
+
* proxy, the same way the engine's `device.installApp()` does.
|
|
51
|
+
*/
|
|
52
|
+
async function installBuild(sandbox, request, appPath, daemon, signal) {
|
|
53
|
+
if (!appPath.toLowerCase().endsWith('.apk')) {
|
|
54
|
+
throw new ConfigurationError('INVALID_CONFIG', `e2b() installs an .apk; app.appPath of target "${request.targetName}" is ${appPath}`);
|
|
55
|
+
}
|
|
56
|
+
// A session of its own, closed before the worker opens its session on the device.
|
|
57
|
+
const client = createAgentDeviceClient({ session: `e2e-install-${sandbox.sandboxId}`, daemonBaseUrl: daemon.baseUrl, daemonAuthToken: daemon.authToken });
|
|
58
|
+
try {
|
|
59
|
+
const installed = await client.apps.install({ appPath, platform: 'android', serial: SERIAL, signal, ...(request.app === undefined ? {} : { app: request.app }) });
|
|
60
|
+
// The same order the mobile engine reads an install's identity in.
|
|
61
|
+
const app = installed.bundleId ?? installed.package ?? installed.appId ?? request.app;
|
|
62
|
+
if (app === undefined)
|
|
63
|
+
throw new Error(`installing ${appPath} reported no package; set app.bundleId to the package to open`);
|
|
64
|
+
return app;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await client.sessions.close({ signal }).catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Polls the proxy's unauthenticated health route through the tunnel until it answers. */
|
|
71
|
+
async function waitForProxy(baseUrl, signal) {
|
|
72
|
+
await poll(`agent-device proxy at ${baseUrl} did not answer`, signal, async (attempt) => {
|
|
73
|
+
const response = await fetch(`${baseUrl}/health`, { signal: attempt });
|
|
74
|
+
if (!response.ok)
|
|
75
|
+
throw new Error(`HTTP ${response.status}`);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* E2B Android for `mobile({ platform: 'android', device: e2b() })`: one
|
|
80
|
+
* `android-16` sandbox per worker slot. Each sandbox runs the engine's
|
|
81
|
+
* agent-device behind `agent-device proxy`, with a bearer token minted per
|
|
82
|
+
* lease, so the runner needs no adb of its own. The sandbox refuses public
|
|
83
|
+
* traffic: the lease's daemon is a loopback tunnel on the runner's machine
|
|
84
|
+
* that adds E2B's traffic access token. A target's `app.appPath` (an `.apk`)
|
|
85
|
+
* is installed through that daemon. Sandboxes are tagged
|
|
86
|
+
* with the run, target, and slot, killed at release, and killed by E2B after
|
|
87
|
+
* `timeoutMs` if the run died first.
|
|
88
|
+
*/
|
|
89
|
+
export function e2b(options = {}) {
|
|
90
|
+
rejectUnknownKeys('e2b()', options, OPTION_KEYS);
|
|
91
|
+
const template = templateOption('e2b()', options.template, DEFAULT_TEMPLATE);
|
|
92
|
+
const timeoutMs = timeoutOption('e2b()', options.timeoutMs, DEFAULT_TIMEOUT_MS);
|
|
93
|
+
const leases = sandboxLeases(sandboxClients());
|
|
94
|
+
/** Sets the sandbox up, starts agent-device behind its proxy, installs the build, and resolves to the lease. */
|
|
95
|
+
async function ready(sandbox, baseUrl, request) {
|
|
96
|
+
request.log(`sandbox ${sandbox.sandboxId}`);
|
|
97
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(SETUP_TIMEOUT_MS)]);
|
|
98
|
+
await sandbox.run(setupScript(request.agentDeviceVersion), { timeoutMs: SETUP_TIMEOUT_MS, signal }).catch((cause) => {
|
|
99
|
+
// The setup's own budget, not the run's interrupt: say which step ran out of time.
|
|
100
|
+
if (signal.aborted && !request.signal.aborted) {
|
|
101
|
+
throw new Error(`Android setup did not finish within ${SETUP_TIMEOUT_MS / 1000}s: installing Node and agent-device, or waiting for Android to boot`, { cause });
|
|
102
|
+
}
|
|
103
|
+
throw cause;
|
|
104
|
+
});
|
|
105
|
+
const daemon = { baseUrl, authToken: randomBytes(32).toString('hex') };
|
|
106
|
+
// Through `envs`, so the token is not in the command E2B receives. Not AGENT_DEVICE_DAEMON_AUTH_TOKEN:
|
|
107
|
+
// the daemon the proxy starts would inherit it. The proxy prints its token, so stdout is dropped.
|
|
108
|
+
await sandbox.start(`exec agent-device proxy --host 0.0.0.0 --port ${PROXY_PORT} --daemon-auth-token "$PROXY_TOKEN" >/dev/null 2>${PROXY_LOG}`, {
|
|
109
|
+
envs: { PROXY_TOKEN: daemon.authToken },
|
|
110
|
+
signal,
|
|
111
|
+
});
|
|
112
|
+
await waitForProxy(baseUrl, signal).catch(async (cause) => {
|
|
113
|
+
throw await withLog(sandbox, PROXY_LOG, cause, daemon.authToken);
|
|
114
|
+
});
|
|
115
|
+
const installedApp = request.appPath === undefined ? undefined : await installBuild(sandbox, request, request.appPath, daemon, signal);
|
|
116
|
+
return { id: sandbox.sandboxId, daemon, deviceId: SERIAL, ...(installedApp === undefined ? {} : { installedApp }) };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
name: 'e2b',
|
|
120
|
+
async acquire(request) {
|
|
121
|
+
if (request.platform !== 'android') {
|
|
122
|
+
throw new ConfigurationError('INVALID_CONFIG', `e2b() serves Android only; target "${request.targetName}" asks for ${request.platform}`);
|
|
123
|
+
}
|
|
124
|
+
if (!SEMVER.test(request.agentDeviceVersion))
|
|
125
|
+
throw new Error(`unexpected agent-device version ${JSON.stringify(request.agentDeviceVersion)}`);
|
|
126
|
+
return leases.acquire(request, { template, timeoutMs, metadata: leaseMetadata(request), port: PROXY_PORT }, (sandbox, tunnel) => ready(sandbox, tunnel.url, request));
|
|
127
|
+
},
|
|
128
|
+
release: (lease, context) => leases.release(lease.id, context),
|
|
129
|
+
};
|
|
130
|
+
}
|
package/dist/tunnel.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A loopback proxy on the runner's machine to one port of one sandbox. The
|
|
3
|
+
* providers create every sandbox with public traffic off, so E2B refuses any
|
|
4
|
+
* request to a sandbox port without the sandbox's traffic access token in a
|
|
5
|
+
* header. The web engine connects with a CDP URL alone, and agent-device's
|
|
6
|
+
* client has no option for a custom header, so both talk to this proxy
|
|
7
|
+
* instead, and it adds the token. A lease carries only the proxy's `127.0.0.1` URL, so the
|
|
8
|
+
* traffic token reaches no lease, log line, or error the engine reports. It answers only requests
|
|
9
|
+
* addressed to that URL.
|
|
10
|
+
*/
|
|
11
|
+
export interface TunnelTarget {
|
|
12
|
+
/** The sandbox port's public host, without the scheme. */
|
|
13
|
+
readonly host: string;
|
|
14
|
+
/** The sandbox's traffic access token. */
|
|
15
|
+
readonly trafficToken: string;
|
|
16
|
+
/** Port and scheme of the public host: 443 over TLS unless a test says otherwise. */
|
|
17
|
+
readonly port?: number | undefined;
|
|
18
|
+
readonly tls?: boolean | undefined;
|
|
19
|
+
}
|
|
20
|
+
export interface Tunnel {
|
|
21
|
+
/** `http://127.0.0.1:<port>`: the base URL that reaches the sandbox port. */
|
|
22
|
+
readonly url: string;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** Opens a tunnel to one sandbox port on a free loopback port. */
|
|
26
|
+
export declare function openTunnel(target: TunnelTarget): Promise<Tunnel>;
|
package/dist/tunnel.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A loopback proxy on the runner's machine to one port of one sandbox. The
|
|
3
|
+
* providers create every sandbox with public traffic off, so E2B refuses any
|
|
4
|
+
* request to a sandbox port without the sandbox's traffic access token in a
|
|
5
|
+
* header. The web engine connects with a CDP URL alone, and agent-device's
|
|
6
|
+
* client has no option for a custom header, so both talk to this proxy
|
|
7
|
+
* instead, and it adds the token. A lease carries only the proxy's `127.0.0.1` URL, so the
|
|
8
|
+
* traffic token reaches no lease, log line, or error the engine reports. It answers only requests
|
|
9
|
+
* addressed to that URL.
|
|
10
|
+
*/
|
|
11
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
12
|
+
import { request as httpsRequest } from 'node:https';
|
|
13
|
+
import { connect as netConnect } from 'node:net';
|
|
14
|
+
import { pipeline } from 'node:stream';
|
|
15
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
16
|
+
/** The header E2B's edge reads a sandbox's traffic access token from. */
|
|
17
|
+
const TRAFFIC_TOKEN_HEADER = 'e2b-traffic-access-token';
|
|
18
|
+
/** The request's headers for the sandbox, with its own `Host` and the traffic token, less `proxy-connection`, which only a proxy reads. */
|
|
19
|
+
function forwardedHeaders(req, target) {
|
|
20
|
+
const headers = { ...req.headers, host: target.host, [TRAFFIC_TOKEN_HEADER]: target.trafficToken };
|
|
21
|
+
delete headers['proxy-connection'];
|
|
22
|
+
return headers;
|
|
23
|
+
}
|
|
24
|
+
/** The request line and forwarded headers of `req`, as the raw HTTP/1.1 head a socket to the sandbox starts with. */
|
|
25
|
+
function requestHead(req, target) {
|
|
26
|
+
const lines = [`${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/1.1`];
|
|
27
|
+
for (const [name, value] of Object.entries(forwardedHeaders(req, target))) {
|
|
28
|
+
// A header Node received more than once arrives as an array: one line each.
|
|
29
|
+
for (const one of [value].flat()) {
|
|
30
|
+
if (one !== undefined)
|
|
31
|
+
lines.push(`${name}: ${one}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return `${lines.join('\r\n')}\r\n\r\n`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether `req` names the tunnel itself as its `Host`. A page in the user's browser that points its own
|
|
38
|
+
* domain at 127.0.0.1 sends that domain instead. The tunnel replaces `Host` on the way out, so
|
|
39
|
+
* Chromium's own check of it never sees the page's: the tunnel makes that check here.
|
|
40
|
+
*/
|
|
41
|
+
function addressedHere(req, port) {
|
|
42
|
+
return req.headers.host === `127.0.0.1:${port}` || req.headers.host === `localhost:${port}`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether `answer` is JSON the tunnel can rewrite. A link in it to the sandbox's public host fails
|
|
46
|
+
* without the token, as agent-device's upload preflight does: it names the `Host` it was asked on.
|
|
47
|
+
*/
|
|
48
|
+
function rewritable(answer) {
|
|
49
|
+
return answer.headers['content-encoding'] === undefined && /^application\/json\s*(;|$)/i.test(answer.headers['content-type'] ?? '');
|
|
50
|
+
}
|
|
51
|
+
/** Sends a JSON `answer` on with every link that matches `publicOrigin` pointed at `url` instead. */
|
|
52
|
+
function sendRewritten(answer, res, publicOrigin, url) {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
answer.on('data', (chunk) => chunks.push(chunk));
|
|
55
|
+
answer.on('error', () => res.destroy());
|
|
56
|
+
answer.on('end', () => {
|
|
57
|
+
const body = Buffer.from(Buffer.concat(chunks).toString('utf8').replace(publicOrigin, url));
|
|
58
|
+
const headers = { ...answer.headers, 'content-length': String(body.length) };
|
|
59
|
+
delete headers['transfer-encoding'];
|
|
60
|
+
res.writeHead(answer.statusCode ?? 502, headers);
|
|
61
|
+
res.end(body);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Joins two sockets: bytes flow both ways, and either one going away ends the other. Node keeps the
|
|
66
|
+
* client's half open after it ends, and an upstream still in its TLS handshake would hold the piped
|
|
67
|
+
* end, so the client's `end` counts as gone too.
|
|
68
|
+
*/
|
|
69
|
+
function relay(client, upstream) {
|
|
70
|
+
client.pipe(upstream).pipe(client);
|
|
71
|
+
upstream.on('error', () => client.destroy());
|
|
72
|
+
client.on('error', () => upstream.destroy());
|
|
73
|
+
upstream.on('close', () => client.destroy());
|
|
74
|
+
client.on('close', () => upstream.destroy());
|
|
75
|
+
client.on('end', () => upstream.destroy());
|
|
76
|
+
}
|
|
77
|
+
/** Opens a tunnel to one sandbox port on a free loopback port. */
|
|
78
|
+
export async function openTunnel(target) {
|
|
79
|
+
const secure = target.tls ?? true;
|
|
80
|
+
const port = target.port ?? (secure ? 443 : 80);
|
|
81
|
+
// The whole host only, not the start of a longer one such as `<host>.example`.
|
|
82
|
+
const publicOrigin = new RegExp(`https?://${target.host.replaceAll('.', '\\.')}(?::\\d+)?(?![\\w.:-])`, 'g');
|
|
83
|
+
const sockets = new Set();
|
|
84
|
+
const track = (socket) => {
|
|
85
|
+
sockets.add(socket);
|
|
86
|
+
socket.once('close', () => sockets.delete(socket));
|
|
87
|
+
};
|
|
88
|
+
let local = 0;
|
|
89
|
+
let url = '';
|
|
90
|
+
const server = createServer((req, res) => {
|
|
91
|
+
if (!addressedHere(req, local)) {
|
|
92
|
+
res.writeHead(403).end();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const send = secure ? httpsRequest : httpRequest;
|
|
96
|
+
const upstream = send({ host: target.host, port, servername: target.host, path: req.url ?? '/', method: req.method, headers: forwardedHeaders(req, target) }, (answer) => {
|
|
97
|
+
if (rewritable(answer)) {
|
|
98
|
+
sendRewritten(answer, res, publicOrigin, url);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
res.writeHead(answer.statusCode ?? 502, answer.headers);
|
|
102
|
+
pipeline(answer, res, () => { });
|
|
103
|
+
});
|
|
104
|
+
// The client went away: stop the request to the sandbox too.
|
|
105
|
+
res.once('close', () => {
|
|
106
|
+
if (!res.writableFinished)
|
|
107
|
+
upstream.destroy();
|
|
108
|
+
});
|
|
109
|
+
// The upstream error names the sandbox host, never the token; the client sees only the status.
|
|
110
|
+
upstream.on('error', () => {
|
|
111
|
+
if (!res.headersSent)
|
|
112
|
+
res.writeHead(502);
|
|
113
|
+
res.end();
|
|
114
|
+
});
|
|
115
|
+
req.pipe(upstream);
|
|
116
|
+
});
|
|
117
|
+
server.on('upgrade', (req, client, head) => {
|
|
118
|
+
track(client);
|
|
119
|
+
// Node drops the server's error handler from an upgraded socket; without one, a reset would crash the run.
|
|
120
|
+
client.on('error', () => client.destroy());
|
|
121
|
+
if (!addressedHere(req, local)) {
|
|
122
|
+
client.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const upstream = secure ? tlsConnect({ host: target.host, port, servername: target.host }) : netConnect(port, target.host);
|
|
126
|
+
track(upstream);
|
|
127
|
+
// The socket holds writes until it connects, so relaying starts at once.
|
|
128
|
+
upstream.write(requestHead(req, target));
|
|
129
|
+
if (head.length > 0)
|
|
130
|
+
upstream.write(head);
|
|
131
|
+
relay(client, upstream);
|
|
132
|
+
});
|
|
133
|
+
server.on('connection', track);
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
server.once('error', reject);
|
|
136
|
+
server.listen(0, '127.0.0.1', () => {
|
|
137
|
+
server.off('error', reject);
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
// An open tunnel never keeps the process alive; a run that ends without releasing exits anyway.
|
|
142
|
+
server.unref();
|
|
143
|
+
local = server.address().port;
|
|
144
|
+
url = `http://127.0.0.1:${local}`;
|
|
145
|
+
return {
|
|
146
|
+
url,
|
|
147
|
+
close: () => new Promise((resolve) => {
|
|
148
|
+
for (const socket of sockets)
|
|
149
|
+
socket.destroy();
|
|
150
|
+
server.close(() => resolve());
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
}
|
package/dist/web.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@e2e-dev/e2b/web` public surface: `e2bBrowsers()`, a browser provider for
|
|
3
|
+
* Chromium in E2B sandboxes on `@e2e-dev/web`. Its types never reference
|
|
4
|
+
* `@e2e-dev/mobile`, and the package root's never reference `@e2e-dev/web`,
|
|
5
|
+
* so a project installs only the engine it uses.
|
|
6
|
+
*/
|
|
7
|
+
export { e2bBrowsers } from './browsers.ts';
|
|
8
|
+
export type { E2BBrowsersOptions } from './browsers.ts';
|
package/dist/web.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@e2e-dev/e2b/web` public surface: `e2bBrowsers()`, a browser provider for
|
|
3
|
+
* Chromium in E2B sandboxes on `@e2e-dev/web`. Its types never reference
|
|
4
|
+
* `@e2e-dev/mobile`, and the package root's never reference `@e2e-dev/web`,
|
|
5
|
+
* so a project installs only the engine it uses.
|
|
6
|
+
*/
|
|
7
|
+
export { e2bBrowsers } from './browsers.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,89 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@e2e-dev/e2b",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "E2B sandboxes for e2e: Android on the android-16 template for mobile tests, and Chromium for web tests",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"e2e",
|
|
7
|
+
"end-to-end",
|
|
8
|
+
"testing",
|
|
9
|
+
"mobile-testing",
|
|
10
|
+
"e2b",
|
|
11
|
+
"sandbox",
|
|
12
|
+
"android-emulator",
|
|
13
|
+
"agent-device",
|
|
14
|
+
"cloud-browsers",
|
|
15
|
+
"hosted-browsers",
|
|
16
|
+
"chromium",
|
|
17
|
+
"cdp",
|
|
18
|
+
"ci"
|
|
19
|
+
],
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"contributors": [
|
|
22
|
+
"Tomas Varga <tomasvarga@sudolabs.io>"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/e2e-inc/e2e.git",
|
|
27
|
+
"directory": "packages/e2b"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/e2e-inc/e2e/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/e2e-inc/e2e/tree/main/packages/e2b#readme",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"registry": "https://registry.npmjs.org/",
|
|
35
|
+
"access": "public",
|
|
36
|
+
"tag": "latest"
|
|
37
|
+
},
|
|
38
|
+
"type": "module",
|
|
39
|
+
"exports": {
|
|
40
|
+
".": {
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"default": "./dist/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./web": {
|
|
45
|
+
"types": "./dist/web.d.ts",
|
|
46
|
+
"default": "./dist/web.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"NOTICE"
|
|
52
|
+
],
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@e2e-dev/mobile": ">=0.12.6 <1",
|
|
55
|
+
"@e2e-dev/web": ">=0.11.0 <1",
|
|
56
|
+
"e2b": ">=2.62.0 <3",
|
|
57
|
+
"e2e": ">=0.15.0 <1"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"@e2e-dev/mobile": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@e2e-dev/web": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@e2e-dev/mobile": "0.12.6",
|
|
69
|
+
"@e2e-dev/web": "0.15.5",
|
|
70
|
+
"@types/node": "26.6.6",
|
|
71
|
+
"e2b": "2.63.0",
|
|
72
|
+
"e2e": "0.23.0",
|
|
73
|
+
"typescript": "7.0.2",
|
|
74
|
+
"vitest": "5.1.0"
|
|
75
|
+
},
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": "^22.22.3 || >=24.8.0"
|
|
78
|
+
},
|
|
79
|
+
"dependencies": {
|
|
80
|
+
"agent-device": "0.21.36"
|
|
81
|
+
},
|
|
82
|
+
"scripts": {
|
|
83
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc --project tsconfig.build.json && node ../../scripts/stamp-dist.ts",
|
|
84
|
+
"typecheck": "tsc --noEmit",
|
|
85
|
+
"test": "vitest run",
|
|
86
|
+
"test:watch": "vitest",
|
|
87
|
+
"test:unit": "vitest run tests/unit"
|
|
88
|
+
}
|
|
6
89
|
}
|