@vostride/agent-qa-plugin-browserstack 0.0.1 → 0.3.1
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 +250 -0
- package/dist/main.js +7 -0
- package/dist/main.js.map +1 -0
- package/dist/options.schema.json +60 -0
- package/dist/plugin-fBvi25yB.js +954 -0
- package/dist/plugin-fBvi25yB.js.map +1 -0
- package/dist/plugin.js +1 -0
- package/dist/settings.schema.json +59 -0
- package/package.json +78 -3
- package/skill/SKILL.md +165 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
import { PluginError, credential, definePlugin, z } from "@vostride/agent-qa-plugin-kit";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
4
|
+
import { openAsBlob, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { format } from "node:util";
|
|
8
|
+
import { Local } from "browserstack-local";
|
|
9
|
+
// src/browserstack-api.ts
|
|
10
|
+
function browserStackFailureKind(status) {
|
|
11
|
+
if (status === void 0) return "unreachable";
|
|
12
|
+
if (status === 401) return "auth";
|
|
13
|
+
if (status === 429) return "capacity";
|
|
14
|
+
return "refused";
|
|
15
|
+
}
|
|
16
|
+
const MAX_REASON_LENGTH = 200;
|
|
17
|
+
function oneLine(text) {
|
|
18
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
19
|
+
return line.length > MAX_REASON_LENGTH ? `${line.slice(0, MAX_REASON_LENGTH)}…` : line;
|
|
20
|
+
}
|
|
21
|
+
function vendorMessage(body) {
|
|
22
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
23
|
+
for (const field of ["message", "error"]) if (field in body) {
|
|
24
|
+
const value = body[field];
|
|
25
|
+
if (typeof value === "string" && value.trim() !== "") return oneLine(value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function parseJson(text) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function statusFailure(response, request) {
|
|
36
|
+
const message = vendorMessage(parseJson(await response.text().catch(() => "")));
|
|
37
|
+
const reason = message === void 0 ? "" : `: ${message}`;
|
|
38
|
+
const retryAfter = response.headers.get("retry-after");
|
|
39
|
+
const wait = response.status === 429 && retryAfter !== null ? `, retry after ${retryAfter} s` : "";
|
|
40
|
+
return new PluginError(`BrowserStack answered ${response.status} to ${request}${reason}${wait}`, { kind: browserStackFailureKind(response.status) });
|
|
41
|
+
}
|
|
42
|
+
function causeText(error) {
|
|
43
|
+
if (!(error instanceof Error)) return oneLine(String(error));
|
|
44
|
+
const cause = error.cause;
|
|
45
|
+
if (cause instanceof Error) return oneLine("code" in cause && typeof cause.code === "string" ? cause.code : cause.message);
|
|
46
|
+
return oneLine(error.message);
|
|
47
|
+
}
|
|
48
|
+
function withSlash(url) {
|
|
49
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
50
|
+
}
|
|
51
|
+
function createBrowserStackApi(input) {
|
|
52
|
+
const appBase = withSlash(input.appApiUrl);
|
|
53
|
+
const automateBase = withSlash(input.apiUrl);
|
|
54
|
+
const authorization = `Basic ${Buffer.from(`${input.username}:${input.accessKey}`).toString("base64")}`;
|
|
55
|
+
const send = async (method, path, init) => {
|
|
56
|
+
const url = new URL(path, init.base ?? appBase);
|
|
57
|
+
try {
|
|
58
|
+
return await fetch(url, {
|
|
59
|
+
method,
|
|
60
|
+
headers: {
|
|
61
|
+
accept: "application/json",
|
|
62
|
+
...init.headers,
|
|
63
|
+
authorization
|
|
64
|
+
},
|
|
65
|
+
...init.body === void 0 ? {} : { body: init.body },
|
|
66
|
+
...init.signal === void 0 ? {} : { signal: init.signal }
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (init.signal?.aborted === true) throw error;
|
|
70
|
+
throw new PluginError(`BrowserStack could not be reached at ${url.origin}: ${causeText(error)}`, { kind: "unreachable" });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const readJson = async (response, request) => {
|
|
74
|
+
if (!response.ok) throw await statusFailure(response, request);
|
|
75
|
+
return parseJson(await response.text());
|
|
76
|
+
};
|
|
77
|
+
const sessionPath = (id) => `app-automate/sessions/${encodeURIComponent(id)}.json`;
|
|
78
|
+
const webSessionPath = (id) => `automate/sessions/${encodeURIComponent(id)}.json`;
|
|
79
|
+
const mark = async (base, path, request, body, signal) => {
|
|
80
|
+
const response = await send("PUT", path, {
|
|
81
|
+
base,
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
signal
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) throw await statusFailure(response, request);
|
|
87
|
+
await response.body?.cancel();
|
|
88
|
+
};
|
|
89
|
+
const plan = async (base, path, signal) => {
|
|
90
|
+
const response = await send("GET", path, {
|
|
91
|
+
base,
|
|
92
|
+
signal
|
|
93
|
+
});
|
|
94
|
+
if (response.status === 404) {
|
|
95
|
+
await response.body?.cancel();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
return readJson(response, `GET /${path}`);
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
async appSession(id, signal) {
|
|
102
|
+
const response = await send("GET", sessionPath(id), { signal });
|
|
103
|
+
return readJson(response, "GET /app-automate/sessions/{id}.json");
|
|
104
|
+
},
|
|
105
|
+
markAppSession(id, body, signal) {
|
|
106
|
+
return mark(appBase, sessionPath(id), "PUT /app-automate/sessions/{id}.json", body, signal);
|
|
107
|
+
},
|
|
108
|
+
async runningBuilds(signal) {
|
|
109
|
+
const response = await send("GET", "automate/builds.json?limit=100&status=running", {
|
|
110
|
+
base: automateBase,
|
|
111
|
+
signal
|
|
112
|
+
});
|
|
113
|
+
return readJson(response, "GET /automate/builds.json");
|
|
114
|
+
},
|
|
115
|
+
async runningBuildSessions(buildId, signal) {
|
|
116
|
+
const path = `automate/builds/${encodeURIComponent(buildId)}/sessions.json?limit=100&status=running`;
|
|
117
|
+
const response = await send("GET", path, {
|
|
118
|
+
base: automateBase,
|
|
119
|
+
signal
|
|
120
|
+
});
|
|
121
|
+
return readJson(response, "GET /automate/builds/{id}/sessions.json");
|
|
122
|
+
},
|
|
123
|
+
async webSession(id, signal) {
|
|
124
|
+
const response = await send("GET", webSessionPath(id), {
|
|
125
|
+
base: automateBase,
|
|
126
|
+
signal
|
|
127
|
+
});
|
|
128
|
+
return readJson(response, "GET /automate/sessions/{id}.json");
|
|
129
|
+
},
|
|
130
|
+
markWebSession(id, body, signal) {
|
|
131
|
+
return mark(automateBase, webSessionPath(id), "PUT /automate/sessions/{id}.json", body, signal);
|
|
132
|
+
},
|
|
133
|
+
automatePlan(signal) {
|
|
134
|
+
return plan(automateBase, "automate/plan.json", signal);
|
|
135
|
+
},
|
|
136
|
+
appAutomatePlan(signal) {
|
|
137
|
+
return plan(appBase, "app-automate/plan.json", signal);
|
|
138
|
+
},
|
|
139
|
+
async recentApps(customId, signal) {
|
|
140
|
+
const path = `app-automate/recent_apps/${encodeURIComponent(customId)}`;
|
|
141
|
+
const response = await send("GET", path, { signal });
|
|
142
|
+
if (response.status === 404) {
|
|
143
|
+
await response.body?.cancel();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
return readJson(response, "GET /app-automate/recent_apps/{custom_id}");
|
|
147
|
+
},
|
|
148
|
+
async uploadApp(form, signal) {
|
|
149
|
+
const response = await send("POST", "app-automate/upload", {
|
|
150
|
+
body: form,
|
|
151
|
+
signal
|
|
152
|
+
});
|
|
153
|
+
return readJson(response, "POST /app-automate/upload");
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function browserStackApiFor(options) {
|
|
158
|
+
return createBrowserStackApi({
|
|
159
|
+
apiUrl: options.apiUrl,
|
|
160
|
+
appApiUrl: options.appApiUrl,
|
|
161
|
+
username: options.username,
|
|
162
|
+
accessKey: options.accessKey
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const MAX_MARK_REASON_LENGTH = 255;
|
|
166
|
+
const DeviceHandleSchema = z.object({
|
|
167
|
+
kind: z.literal("device"),
|
|
168
|
+
id: z.string().min(1),
|
|
169
|
+
platform: z.enum(["android", "ios"]),
|
|
170
|
+
appiumVersion: z.string().min(1)
|
|
171
|
+
});
|
|
172
|
+
function ownAppiumVersion(capabilities) {
|
|
173
|
+
const own = capabilities["bstack:options"];
|
|
174
|
+
if (typeof own !== "object" || own === null || !("appiumVersion" in own)) return void 0;
|
|
175
|
+
const { appiumVersion } = own;
|
|
176
|
+
return typeof appiumVersion === "string" && appiumVersion !== "" ? appiumVersion : void 0;
|
|
177
|
+
}
|
|
178
|
+
function deviceAnswer(request) {
|
|
179
|
+
const { options, settings } = request;
|
|
180
|
+
const appiumVersion = ownAppiumVersion(request.capabilities) ?? settings.appiumVersion ?? "3.5.2";
|
|
181
|
+
const build = settings.build ?? request.runId;
|
|
182
|
+
const name = request.testName;
|
|
183
|
+
return {
|
|
184
|
+
handle: {
|
|
185
|
+
kind: "device",
|
|
186
|
+
id: randomUUID(),
|
|
187
|
+
platform: request.platform,
|
|
188
|
+
appiumVersion,
|
|
189
|
+
...build === void 0 ? {} : { build },
|
|
190
|
+
...name === void 0 ? {} : { name }
|
|
191
|
+
},
|
|
192
|
+
protocol: "appium",
|
|
193
|
+
url: options.hubUrl,
|
|
194
|
+
capabilities: { "bstack:options": {
|
|
195
|
+
userName: options.username,
|
|
196
|
+
accessKey: options.accessKey,
|
|
197
|
+
appiumVersion,
|
|
198
|
+
projectName: settings.project,
|
|
199
|
+
...build === void 0 ? {} : { buildName: build },
|
|
200
|
+
...name === void 0 ? {} : { sessionName: name },
|
|
201
|
+
...request.localIdentifier === void 0 ? {} : {
|
|
202
|
+
local: true,
|
|
203
|
+
localIdentifier: request.localIdentifier
|
|
204
|
+
},
|
|
205
|
+
...settings.extra
|
|
206
|
+
} },
|
|
207
|
+
mask: []
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function sessionDetails(body) {
|
|
211
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
212
|
+
const wrapped = body.automation_session;
|
|
213
|
+
if (typeof wrapped === "object" && wrapped !== null) return wrapped;
|
|
214
|
+
return body;
|
|
215
|
+
}
|
|
216
|
+
function warn(line) {
|
|
217
|
+
process.stderr.write(`${line.replace(/\s+/g, " ").trim()}\n`);
|
|
218
|
+
}
|
|
219
|
+
function failureText(error) {
|
|
220
|
+
return error instanceof Error ? error.message : String(error);
|
|
221
|
+
}
|
|
222
|
+
async function deviceStarted(input) {
|
|
223
|
+
const { appiumVersion } = input.handle;
|
|
224
|
+
if (input.sessionId === void 0) return { appiumVersion };
|
|
225
|
+
try {
|
|
226
|
+
const link = sessionDetails(await browserStackApiFor(input.options).appSession(input.sessionId, input.signal))?.browser_url;
|
|
227
|
+
if (typeof link === "string" && link !== "") return {
|
|
228
|
+
link,
|
|
229
|
+
appiumVersion
|
|
230
|
+
};
|
|
231
|
+
warn(`BrowserStack: App Automate session ${input.sessionId} has no dashboard link.`);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
warn(`BrowserStack: could not read App Automate session ${input.sessionId}: ${failureText(error)}`);
|
|
234
|
+
}
|
|
235
|
+
return { appiumVersion };
|
|
236
|
+
}
|
|
237
|
+
function sessionMark(outcome, reason) {
|
|
238
|
+
if (outcome === "cancelled") return void 0;
|
|
239
|
+
if (outcome === "passed") return { status: "passed" };
|
|
240
|
+
return {
|
|
241
|
+
status: "failed",
|
|
242
|
+
...reason === void 0 ? {} : { reason: reason.slice(0, MAX_MARK_REASON_LENGTH) }
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async function markAppSession(input) {
|
|
246
|
+
const mark = sessionMark(input.outcome, input.reason);
|
|
247
|
+
if (mark === void 0) return;
|
|
248
|
+
try {
|
|
249
|
+
await browserStackApiFor(input.options).markAppSession(input.sessionId, mark, input.signal);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
warn(`BrowserStack: could not mark App Automate session ${input.sessionId} ${mark.status}: ${failureText(error)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// src/playwright-versions.ts
|
|
255
|
+
const BROWSERSTACK_PLAYWRIGHT_VERSIONS = [
|
|
256
|
+
"1.59",
|
|
257
|
+
"1.60",
|
|
258
|
+
"1.61",
|
|
259
|
+
"1.62",
|
|
260
|
+
"1.63"
|
|
261
|
+
];
|
|
262
|
+
function playwrightMajorMinor(version) {
|
|
263
|
+
const match = /^(\d+)\.(\d+)(?:\.|$|-)/.exec(version);
|
|
264
|
+
return match === null ? void 0 : `${match[1]}.${match[2]}`;
|
|
265
|
+
}
|
|
266
|
+
function listedPlaywright(version) {
|
|
267
|
+
const majorMinor = playwrightMajorMinor(version);
|
|
268
|
+
return BROWSERSTACK_PLAYWRIGHT_VERSIONS.some((listed) => listed === majorMinor);
|
|
269
|
+
}
|
|
270
|
+
// src/check.ts
|
|
271
|
+
const PlanSchema = z.object({
|
|
272
|
+
parallel_sessions_running: z.number(),
|
|
273
|
+
parallel_sessions_max_allowed: z.number(),
|
|
274
|
+
queued_sessions: z.number()
|
|
275
|
+
});
|
|
276
|
+
async function readPlan(product, read) {
|
|
277
|
+
let answer;
|
|
278
|
+
try {
|
|
279
|
+
answer = await read();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error instanceof PluginError && error.kind === "auth") return { kind: "refused" };
|
|
282
|
+
return {
|
|
283
|
+
kind: "warn",
|
|
284
|
+
line: `${product} plan not readable: ${failureText(error)}`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (answer === void 0) return {
|
|
288
|
+
kind: "warn",
|
|
289
|
+
line: `${product} is not on this account`
|
|
290
|
+
};
|
|
291
|
+
const plan = PlanSchema.safeParse(answer);
|
|
292
|
+
if (!plan.success) return {
|
|
293
|
+
kind: "warn",
|
|
294
|
+
line: `${product} plan not readable: BrowserStack answered another shape`
|
|
295
|
+
};
|
|
296
|
+
const { parallel_sessions_running: running, parallel_sessions_max_allowed: allowed } = plan.data;
|
|
297
|
+
const queued = plan.data.queued_sessions;
|
|
298
|
+
return {
|
|
299
|
+
kind: "plan",
|
|
300
|
+
line: `${product}: ${String(running)} of ${String(allowed)} parallel sessions running, ${String(queued)} queued`
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function playwrightLine(version) {
|
|
304
|
+
if (listedPlaywright(version)) return void 0;
|
|
305
|
+
const first = BROWSERSTACK_PLAYWRIGHT_VERSIONS[0];
|
|
306
|
+
const last = BROWSERSTACK_PLAYWRIGHT_VERSIONS[BROWSERSTACK_PLAYWRIGHT_VERSIONS.length - 1];
|
|
307
|
+
const ours = playwrightMajorMinor(version);
|
|
308
|
+
if (ours === void 0) return `BrowserStack lists Playwright ${first} to ${last}, and agent-qa's Playwright version is unknown; web tests on BrowserStack are refused until it is known.`;
|
|
309
|
+
return `BrowserStack lists Playwright ${first} to ${last}, not ${ours}; web tests on BrowserStack are refused until it does.`;
|
|
310
|
+
}
|
|
311
|
+
async function checkAccount(input) {
|
|
312
|
+
const api = browserStackApiFor(input.options);
|
|
313
|
+
const reads = await Promise.all([readPlan("Automate", () => api.automatePlan(input.signal)), readPlan("App Automate", () => api.appAutomatePlan(input.signal))]);
|
|
314
|
+
const version = playwrightLine(input.playwrightVersion);
|
|
315
|
+
const extra = version === void 0 ? [] : [version];
|
|
316
|
+
if (reads.some((read) => read.kind === "refused")) return {
|
|
317
|
+
state: "warn",
|
|
318
|
+
lines: ["the username or access key was refused", ...extra]
|
|
319
|
+
};
|
|
320
|
+
const lines = reads.flatMap((read) => read.kind === "refused" ? [] : [read.line]);
|
|
321
|
+
return {
|
|
322
|
+
state: extra.length > 0 || reads.some((read) => read.kind === "warn") ? "warn" : "ok",
|
|
323
|
+
lines: [...lines, ...extra]
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// src/local.ts
|
|
327
|
+
const STOP_BOUND_MS = 3e3;
|
|
328
|
+
const CHECK_BOUND_MS = 5e3;
|
|
329
|
+
const BAD_CPU_TYPE = -86;
|
|
330
|
+
function cannotRunReason(binary, error, platform) {
|
|
331
|
+
if (platform === "darwin" && error.errno === BAD_CPU_TYPE) return `this machine cannot run ${binary} (EBADARCH: bad CPU type in executable). Install Rosetta (softwareupdate --install-rosetta --agree-to-license) or set localBinary to a binary built for this Mac`;
|
|
332
|
+
const code = error.code ?? error.message;
|
|
333
|
+
if (code === "ENOENT") return `${binary} does not exist (ENOENT). Set localBinary to the path of a BrowserStackLocal binary`;
|
|
334
|
+
if (code === "EACCES") return `this machine cannot run ${binary} (EACCES). Check that it is an executable file, or set localBinary to one that is`;
|
|
335
|
+
return `this machine cannot run ${binary} (${code}). Set localBinary to a binary this machine can run`;
|
|
336
|
+
}
|
|
337
|
+
function wrapperBinary(wrapper, accessKey) {
|
|
338
|
+
wrapper.key = accessKey;
|
|
339
|
+
return new Promise((resolve, reject) => {
|
|
340
|
+
wrapper.getBinaryPath((path) => {
|
|
341
|
+
if (path) {
|
|
342
|
+
resolve(path);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
reject(/* @__PURE__ */ new Error("browserstack-local found no BrowserStack Local binary and could not download one"));
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function cannotRun(binary) {
|
|
350
|
+
const { error } = spawnSync(binary, ["--version"], {
|
|
351
|
+
stdio: "ignore",
|
|
352
|
+
timeout: CHECK_BOUND_MS
|
|
353
|
+
});
|
|
354
|
+
if (error === void 0) return void 0;
|
|
355
|
+
return error.code === "ETIMEDOUT" ? void 0 : error;
|
|
356
|
+
}
|
|
357
|
+
function stopDaemon(binary, request) {
|
|
358
|
+
const args = [
|
|
359
|
+
"--daemon",
|
|
360
|
+
"stop",
|
|
361
|
+
"--local-identifier",
|
|
362
|
+
request.identifier
|
|
363
|
+
];
|
|
364
|
+
return new Promise((done) => {
|
|
365
|
+
execFile(binary, [
|
|
366
|
+
...args,
|
|
367
|
+
"--key",
|
|
368
|
+
request.accessKey
|
|
369
|
+
], { timeout: STOP_BOUND_MS }, () => done());
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const CONSOLE_METHODS = [
|
|
373
|
+
"log",
|
|
374
|
+
"info",
|
|
375
|
+
"debug",
|
|
376
|
+
"warn",
|
|
377
|
+
"error"
|
|
378
|
+
];
|
|
379
|
+
function maskConsole(secret) {
|
|
380
|
+
if (secret === "") return () => {};
|
|
381
|
+
const originals = CONSOLE_METHODS.map((method) => console[method]);
|
|
382
|
+
CONSOLE_METHODS.forEach((method, index) => {
|
|
383
|
+
const original = originals[index];
|
|
384
|
+
console[method] = (...args) => original(format(...args).split(secret).join("***"));
|
|
385
|
+
});
|
|
386
|
+
return () => {
|
|
387
|
+
CONSOLE_METHODS.forEach((method, index) => {
|
|
388
|
+
console[method] = originals[index];
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function localLogFile() {
|
|
393
|
+
const name = `agent-qa-browserstack-local-${process.pid}-${randomBytes(4).toString("hex")}.log`;
|
|
394
|
+
return join(tmpdir(), name);
|
|
395
|
+
}
|
|
396
|
+
const browserStackLocalFactory = { start(request) {
|
|
397
|
+
const local = new Local();
|
|
398
|
+
const wrapper = local;
|
|
399
|
+
wrapper.retriesLeft = 0;
|
|
400
|
+
const unmask = maskConsole(request.accessKey);
|
|
401
|
+
let aborted = false;
|
|
402
|
+
return {
|
|
403
|
+
running: (async () => {
|
|
404
|
+
const binary = request.binary ?? await wrapperBinary(wrapper, request.accessKey);
|
|
405
|
+
const failure = cannotRun(binary);
|
|
406
|
+
if (failure !== void 0) throw new Error(cannotRunReason(binary, failure, process.platform));
|
|
407
|
+
if (aborted) throw new Error("it was stopped while it was starting");
|
|
408
|
+
const logFile = localLogFile();
|
|
409
|
+
writeFileSync(logFile, "", { mode: 384 });
|
|
410
|
+
return new Promise((resolve, reject) => {
|
|
411
|
+
local.start({
|
|
412
|
+
key: request.accessKey,
|
|
413
|
+
localIdentifier: request.identifier,
|
|
414
|
+
logFile,
|
|
415
|
+
binarypath: binary
|
|
416
|
+
}, (error) => {
|
|
417
|
+
if (error) {
|
|
418
|
+
const [first] = error.message.split("\n");
|
|
419
|
+
reject(/* @__PURE__ */ new Error(`${first} (its log is ${logFile})`));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
resolve({ stop: () => new Promise((done) => local.stop(() => done())) });
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
})().finally(unmask),
|
|
426
|
+
async abort() {
|
|
427
|
+
aborted = true;
|
|
428
|
+
wrapper.tunnel?.kill();
|
|
429
|
+
if (wrapper.binaryPath !== void 0) await stopDaemon(wrapper.binaryPath, request);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
} };
|
|
433
|
+
function freshLocalIdentifier() {
|
|
434
|
+
return `agent-qa-${randomBytes(8).toString("hex")}`;
|
|
435
|
+
}
|
|
436
|
+
function startFailure(error, accessKey, binarySet) {
|
|
437
|
+
if (error instanceof PluginError) return error;
|
|
438
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
439
|
+
const shown = accessKey === "" ? text : text.split(accessKey).join("***");
|
|
440
|
+
return new PluginError(`BrowserStack Local did not start: ${shown}`, {
|
|
441
|
+
kind: "unreachable",
|
|
442
|
+
path: binarySet ? "options.localBinary" : "options.local"
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function bounded(work, boundMs) {
|
|
446
|
+
let timer;
|
|
447
|
+
const bound = new Promise((resolve) => {
|
|
448
|
+
timer = setTimeout(resolve, boundMs);
|
|
449
|
+
});
|
|
450
|
+
return Promise.race([work.catch(() => {}), bound]).finally(() => clearTimeout(timer));
|
|
451
|
+
}
|
|
452
|
+
function createTunnelManager(input) {
|
|
453
|
+
const identifier = input.local ? input.localIdentifier ?? freshLocalIdentifier() : input.localIdentifier;
|
|
454
|
+
const binarySet = input.localBinary !== void 0;
|
|
455
|
+
let starting;
|
|
456
|
+
let stopped = false;
|
|
457
|
+
let connected = false;
|
|
458
|
+
const start = () => {
|
|
459
|
+
if (starting !== void 0) return starting.running;
|
|
460
|
+
let begun;
|
|
461
|
+
try {
|
|
462
|
+
begun = input.factory.start({
|
|
463
|
+
accessKey: input.accessKey,
|
|
464
|
+
identifier,
|
|
465
|
+
binary: input.localBinary
|
|
466
|
+
});
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return Promise.reject(startFailure(error, input.accessKey, binarySet));
|
|
469
|
+
}
|
|
470
|
+
const attempt = {
|
|
471
|
+
running: begun.running.catch((error) => {
|
|
472
|
+
throw startFailure(error, input.accessKey, binarySet);
|
|
473
|
+
}),
|
|
474
|
+
abort: () => begun.abort()
|
|
475
|
+
};
|
|
476
|
+
starting = attempt;
|
|
477
|
+
attempt.running.then(() => {
|
|
478
|
+
connected = true;
|
|
479
|
+
}, () => {
|
|
480
|
+
if (starting === attempt) starting = void 0;
|
|
481
|
+
});
|
|
482
|
+
return attempt.running;
|
|
483
|
+
};
|
|
484
|
+
return {
|
|
485
|
+
identifier,
|
|
486
|
+
async ensure(signal, progress) {
|
|
487
|
+
if (!input.local) return;
|
|
488
|
+
if (stopped) throw new PluginError("BrowserStack Local was already stopped for this run.", {
|
|
489
|
+
kind: "unreachable",
|
|
490
|
+
path: "options.local"
|
|
491
|
+
});
|
|
492
|
+
signal.throwIfAborted();
|
|
493
|
+
const shared = start();
|
|
494
|
+
if (!connected) progress({ message: "starting BrowserStack Local" });
|
|
495
|
+
await new Promise((resolve, reject) => {
|
|
496
|
+
const onAbort = () => reject(/* @__PURE__ */ new Error("the acquire was cancelled while BrowserStack Local was starting"));
|
|
497
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
498
|
+
shared.then(() => {
|
|
499
|
+
signal.removeEventListener("abort", onAbort);
|
|
500
|
+
resolve();
|
|
501
|
+
}, (error) => {
|
|
502
|
+
signal.removeEventListener("abort", onAbort);
|
|
503
|
+
reject(error);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
},
|
|
507
|
+
async stop() {
|
|
508
|
+
if (stopped) return;
|
|
509
|
+
stopped = true;
|
|
510
|
+
const pending = starting;
|
|
511
|
+
if (pending === void 0) return;
|
|
512
|
+
const stopRunning = pending.running.then((tunnel) => tunnel.stop());
|
|
513
|
+
if (connected) {
|
|
514
|
+
await bounded(stopRunning, STOP_BOUND_MS);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
await bounded(Promise.allSettled([pending.abort(), stopRunning]).then(() => {}), STOP_BOUND_MS);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
// src/options.ts
|
|
522
|
+
const BROWSERSTACK_BROWSERS = [
|
|
523
|
+
"chrome",
|
|
524
|
+
"edge",
|
|
525
|
+
"playwright-chromium",
|
|
526
|
+
"playwright-firefox",
|
|
527
|
+
"playwright-webkit"
|
|
528
|
+
];
|
|
529
|
+
const options = z.strictObject({
|
|
530
|
+
username: credential("The BrowserStack username"),
|
|
531
|
+
accessKey: credential("The BrowserStack access key"),
|
|
532
|
+
apiUrl: z.url().default("https://api.browserstack.com").describe("BrowserStack's Automate API, https:// unless it points at this machine"),
|
|
533
|
+
appApiUrl: z.url().default("https://api-cloud.browserstack.com").describe("BrowserStack's App Automate API, https:// unless it points at this machine"),
|
|
534
|
+
hubUrl: z.url().default("https://hub.browserstack.com/wd/hub").describe("BrowserStack's Appium hub, https:// unless it points at this machine"),
|
|
535
|
+
playwrightUrl: z.url().default("wss://cdp.browserstack.com/playwright").describe("BrowserStack's Playwright endpoint, wss:// unless it points at this machine"),
|
|
536
|
+
local: z.boolean().default(false).describe("Start a BrowserStack Local tunnel for this run, named by localIdentifier or a fresh one"),
|
|
537
|
+
localIdentifier: z.string().min(1).optional().describe("The identifier of a BrowserStack Local tunnel; without local, a tunnel already running"),
|
|
538
|
+
localBinary: z.string().min(1).optional().describe("The BrowserStackLocal binary to run instead of the one the wrapper downloads")
|
|
539
|
+
});
|
|
540
|
+
const settings = z.strictObject({
|
|
541
|
+
os: z.string().min(1).default("OS X").describe("The operating system, for example Windows"),
|
|
542
|
+
osVersion: z.string().min(1).default("Sequoia").describe("The operating system version, for example 11 or Sequoia"),
|
|
543
|
+
browser: z.enum(BROWSERSTACK_BROWSERS).optional().describe("BrowserStack's browser; by default it follows the test's engine"),
|
|
544
|
+
browserVersion: z.string().min(1).optional().describe("The Chrome or Edge version, for example latest or latest-1"),
|
|
545
|
+
project: z.string().min(1).default("agent-qa").describe("The BrowserStack project the sessions are filed under"),
|
|
546
|
+
build: z.string().min(1).optional().describe("The BrowserStack build name; by default the run id"),
|
|
547
|
+
appiumVersion: z.string().min(1).optional().describe("The Appium version device sessions ask for; by default the plugin's own"),
|
|
548
|
+
extra: z.record(z.string(), z.unknown()).optional().describe("Capabilities merged last, overriding the ones above")
|
|
549
|
+
});
|
|
550
|
+
const ENDPOINTS = [
|
|
551
|
+
{
|
|
552
|
+
path: "apiUrl",
|
|
553
|
+
secure: "https:",
|
|
554
|
+
plain: "http:"
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
path: "appApiUrl",
|
|
558
|
+
secure: "https:",
|
|
559
|
+
plain: "http:"
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
path: "hubUrl",
|
|
563
|
+
secure: "https:",
|
|
564
|
+
plain: "http:"
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
path: "playwrightUrl",
|
|
568
|
+
secure: "wss:",
|
|
569
|
+
plain: "ws:"
|
|
570
|
+
}
|
|
571
|
+
];
|
|
572
|
+
function isLoopback(hostname) {
|
|
573
|
+
return hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
574
|
+
}
|
|
575
|
+
function checkLocal(input) {
|
|
576
|
+
if (input.localBinary !== void 0 && !input.local) throw new PluginError("localBinary names the tunnel the plugin starts, so it needs local: true.", {
|
|
577
|
+
kind: "refused",
|
|
578
|
+
path: "localBinary"
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
function checkEndpoints(input) {
|
|
582
|
+
for (const endpoint of ENDPOINTS) {
|
|
583
|
+
const url = new URL(input[endpoint.path]);
|
|
584
|
+
if (!(url.username === "" && url.password === "" && (url.protocol === endpoint.secure || url.protocol === endpoint.plain && isLoopback(url.hostname)))) {
|
|
585
|
+
const scheme = endpoint.secure.slice(0, -1);
|
|
586
|
+
throw new PluginError(`${endpoint.path} must use ${scheme}:// with no user or password, unless it points at this machine.`, {
|
|
587
|
+
kind: "refused",
|
|
588
|
+
path: endpoint.path
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// src/upload.ts
|
|
594
|
+
const MEGABYTE = 1048576;
|
|
595
|
+
function customIdFor(sha256) {
|
|
596
|
+
if (!/^[0-9a-f]{64}$/i.test(sha256)) throw new PluginError("sha256 must be the 64 hex digits of the app's SHA-256.", {
|
|
597
|
+
kind: "refused",
|
|
598
|
+
path: "sha256"
|
|
599
|
+
});
|
|
600
|
+
return `agent-qa-${sha256.slice(0, 32).toLowerCase()}`;
|
|
601
|
+
}
|
|
602
|
+
function isRecord(value) {
|
|
603
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
604
|
+
}
|
|
605
|
+
function appUrlOf(value) {
|
|
606
|
+
if (!isRecord(value)) return void 0;
|
|
607
|
+
const appUrl = value.app_url;
|
|
608
|
+
return typeof appUrl === "string" && appUrl !== "" ? appUrl : void 0;
|
|
609
|
+
}
|
|
610
|
+
function uploadedAt(value) {
|
|
611
|
+
const at = typeof value.uploaded_at === "string" ? Date.parse(value.uploaded_at) : NaN;
|
|
612
|
+
return Number.isNaN(at) ? Number.NEGATIVE_INFINITY : at;
|
|
613
|
+
}
|
|
614
|
+
function newestAppUrl(recent) {
|
|
615
|
+
if (!Array.isArray(recent)) return void 0;
|
|
616
|
+
const newest = [...recent.filter(isRecord).filter((app) => appUrlOf(app) !== void 0)].sort((a, b) => uploadedAt(b) - uploadedAt(a))[0];
|
|
617
|
+
return appUrlOf(newest);
|
|
618
|
+
}
|
|
619
|
+
function megabytes(size) {
|
|
620
|
+
const amount = size / MEGABYTE;
|
|
621
|
+
return amount >= .1 ? amount.toFixed(1) : "<0.1";
|
|
622
|
+
}
|
|
623
|
+
async function readApp(path) {
|
|
624
|
+
try {
|
|
625
|
+
return await openAsBlob(path);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "unreadable";
|
|
628
|
+
throw new PluginError(`The app at ${path} cannot be read (${code}).`, {
|
|
629
|
+
kind: "refused",
|
|
630
|
+
path: "path"
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function checkContent(app, request) {
|
|
635
|
+
const changed = () => new PluginError(`The app at ${request.path} no longer matches the SHA-256 agent-qa computed; run again.`, {
|
|
636
|
+
kind: "refused",
|
|
637
|
+
path: "path"
|
|
638
|
+
});
|
|
639
|
+
if (app.size !== request.size) throw changed();
|
|
640
|
+
const hash = createHash("sha256");
|
|
641
|
+
try {
|
|
642
|
+
for await (const chunk of app.stream()) {
|
|
643
|
+
request.signal.throwIfAborted();
|
|
644
|
+
hash.update(chunk);
|
|
645
|
+
}
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (request.signal.aborted) throw error;
|
|
648
|
+
throw changed();
|
|
649
|
+
}
|
|
650
|
+
if (hash.digest("hex") !== request.sha256.toLowerCase()) throw changed();
|
|
651
|
+
}
|
|
652
|
+
async function uploadApp(request) {
|
|
653
|
+
const customId = customIdFor(request.sha256);
|
|
654
|
+
const api = browserStackApiFor(request.options);
|
|
655
|
+
const reused = newestAppUrl(await api.recentApps(customId, request.signal));
|
|
656
|
+
if (reused !== void 0) return { ref: reused };
|
|
657
|
+
const app = await readApp(request.path);
|
|
658
|
+
await checkContent(app, request);
|
|
659
|
+
const form = new FormData();
|
|
660
|
+
form.append("custom_id", customId);
|
|
661
|
+
form.append("file", app, basename(request.path));
|
|
662
|
+
request.progress({ message: `uploading app (${megabytes(request.size)} MB)` });
|
|
663
|
+
const ref = appUrlOf(await api.uploadApp(form, request.signal));
|
|
664
|
+
if (ref === void 0) throw new PluginError("BrowserStack answered the app upload without an app_url.", { kind: "refused" });
|
|
665
|
+
request.progress({ message: "app uploaded" });
|
|
666
|
+
return { ref };
|
|
667
|
+
}
|
|
668
|
+
// src/web.ts
|
|
669
|
+
const START_SLACK_MS = 5e3;
|
|
670
|
+
const WebHandleSchema = z.object({
|
|
671
|
+
kind: z.literal("web"),
|
|
672
|
+
id: z.string().min(1),
|
|
673
|
+
build: z.string().optional(),
|
|
674
|
+
name: z.string().optional(),
|
|
675
|
+
startedAt: z.string()
|
|
676
|
+
});
|
|
677
|
+
const ENGINE_BROWSERS = {
|
|
678
|
+
chromium: "chrome",
|
|
679
|
+
firefox: "playwright-firefox",
|
|
680
|
+
webkit: "playwright-webkit"
|
|
681
|
+
};
|
|
682
|
+
const BROWSER_ENGINES = {
|
|
683
|
+
chrome: "chromium",
|
|
684
|
+
edge: "chromium",
|
|
685
|
+
"playwright-chromium": "chromium",
|
|
686
|
+
"playwright-firefox": "firefox",
|
|
687
|
+
"playwright-webkit": "webkit"
|
|
688
|
+
};
|
|
689
|
+
function checkPlaywrightVersion(version) {
|
|
690
|
+
if (listedPlaywright(version)) return;
|
|
691
|
+
const first = BROWSERSTACK_PLAYWRIGHT_VERSIONS[0];
|
|
692
|
+
const last = BROWSERSTACK_PLAYWRIGHT_VERSIONS[BROWSERSTACK_PLAYWRIGHT_VERSIONS.length - 1];
|
|
693
|
+
const ours = playwrightMajorMinor(version);
|
|
694
|
+
const which = ours === void 0 ? "and agent-qa's Playwright version is unknown" : `not ${ours}`;
|
|
695
|
+
throw new PluginError(`BrowserStack lists Playwright ${first} to ${last}, ${which}, so web tests cannot run there yet; run this test here or on another remote.`, { kind: "version" });
|
|
696
|
+
}
|
|
697
|
+
function webCapabilities(request) {
|
|
698
|
+
const { options, settings, engine } = request;
|
|
699
|
+
const browser = settings.browser ?? ENGINE_BROWSERS[engine];
|
|
700
|
+
if (BROWSER_ENGINES[browser] !== engine) throw new PluginError(`BrowserStack's ${browser} cannot run a ${engine} test.`, {
|
|
701
|
+
kind: "refused",
|
|
702
|
+
path: "settings.browser"
|
|
703
|
+
});
|
|
704
|
+
const build = settings.build ?? request.runId;
|
|
705
|
+
return {
|
|
706
|
+
"browserstack.username": options.username,
|
|
707
|
+
"browserstack.accessKey": options.accessKey,
|
|
708
|
+
browser,
|
|
709
|
+
os: settings.os,
|
|
710
|
+
os_version: settings.osVersion,
|
|
711
|
+
...settings.browserVersion === void 0 ? {} : { browser_version: settings.browserVersion },
|
|
712
|
+
"client.playwrightVersion": request.playwrightVersion,
|
|
713
|
+
project: settings.project,
|
|
714
|
+
...build === void 0 ? {} : { build },
|
|
715
|
+
...request.testName === void 0 ? {} : { name: request.testName },
|
|
716
|
+
...request.localIdentifier === void 0 ? {} : {
|
|
717
|
+
"browserstack.local": true,
|
|
718
|
+
"browserstack.localIdentifier": request.localIdentifier
|
|
719
|
+
},
|
|
720
|
+
...settings.extra
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function playwrightAnswer(request) {
|
|
724
|
+
checkPlaywrightVersion(request.playwrightVersion);
|
|
725
|
+
const caps = webCapabilities(request);
|
|
726
|
+
const base = request.options.playwrightUrl;
|
|
727
|
+
const query = `caps=${encodeURIComponent(JSON.stringify(caps))}`;
|
|
728
|
+
const url = `${base}${base.includes("?") ? "&" : "?"}${query}`;
|
|
729
|
+
const { build, name } = caps;
|
|
730
|
+
return {
|
|
731
|
+
handle: {
|
|
732
|
+
kind: "web",
|
|
733
|
+
id: randomUUID(),
|
|
734
|
+
...typeof build === "string" ? { build } : {},
|
|
735
|
+
...typeof name === "string" ? { name } : {},
|
|
736
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
737
|
+
},
|
|
738
|
+
protocol: "playwright",
|
|
739
|
+
url,
|
|
740
|
+
mask: [url]
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function itemsOf(body, wrapper) {
|
|
744
|
+
if (!Array.isArray(body)) return [];
|
|
745
|
+
return body.flatMap((item) => {
|
|
746
|
+
if (typeof item !== "object" || item === null) return [];
|
|
747
|
+
const wrapped = item[wrapper];
|
|
748
|
+
if (typeof wrapped === "object" && wrapped !== null) return [wrapped];
|
|
749
|
+
return [item];
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
function text(value) {
|
|
753
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
754
|
+
}
|
|
755
|
+
async function findWebSession(input) {
|
|
756
|
+
const { api, signal } = input;
|
|
757
|
+
const earliest = Date.parse(input.startedAt) - START_SLACK_MS;
|
|
758
|
+
const builds = itemsOf(await api.runningBuilds(signal), "automation_build").filter((build) => build.name === input.build);
|
|
759
|
+
const found = [];
|
|
760
|
+
for (const build of builds) {
|
|
761
|
+
const buildId = text(build.hashed_id);
|
|
762
|
+
if (buildId === void 0) continue;
|
|
763
|
+
const sessions = itemsOf(await api.runningBuildSessions(buildId, signal), "automation_session").filter((session) => session.name === input.name);
|
|
764
|
+
for (const session of sessions) {
|
|
765
|
+
const hashedId = text(session.hashed_id);
|
|
766
|
+
if (hashedId === void 0) continue;
|
|
767
|
+
const details = sessionDetails(await api.webSession(hashedId, signal));
|
|
768
|
+
const createdAt = Date.parse(text(details?.created_at) ?? "");
|
|
769
|
+
if (!Number.isFinite(createdAt) || createdAt < earliest) continue;
|
|
770
|
+
found.push({
|
|
771
|
+
hashedId,
|
|
772
|
+
link: text(details?.browser_url) ?? text(session.browser_url)
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return found.length === 1 ? found[0] : void 0;
|
|
777
|
+
}
|
|
778
|
+
async function webStarted(input) {
|
|
779
|
+
const { build, name, startedAt } = input.handle;
|
|
780
|
+
const unmarked = `BrowserStack: no single web session matched build ${build ?? "(none)"} and name ${name ?? "(none)"}; it stays unmarked.`;
|
|
781
|
+
if (build === void 0 || name === void 0) {
|
|
782
|
+
warn(unmarked);
|
|
783
|
+
return { session: void 0 };
|
|
784
|
+
}
|
|
785
|
+
let session;
|
|
786
|
+
try {
|
|
787
|
+
session = await findWebSession({
|
|
788
|
+
api: browserStackApiFor(input.options),
|
|
789
|
+
build,
|
|
790
|
+
name,
|
|
791
|
+
startedAt,
|
|
792
|
+
signal: input.signal
|
|
793
|
+
});
|
|
794
|
+
} catch (error) {
|
|
795
|
+
warn(`BrowserStack: could not look up the web session for build ${build} and name ${name}: ${failureText(error)}; it stays unmarked.`);
|
|
796
|
+
return { session: void 0 };
|
|
797
|
+
}
|
|
798
|
+
if (session === void 0) {
|
|
799
|
+
warn(unmarked);
|
|
800
|
+
return { session: void 0 };
|
|
801
|
+
}
|
|
802
|
+
if (session.link === void 0) {
|
|
803
|
+
warn(`BrowserStack: Automate session ${session.hashedId} has no dashboard link.`);
|
|
804
|
+
return { session };
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
session,
|
|
808
|
+
link: session.link
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
async function markWebSession(input) {
|
|
812
|
+
const mark = sessionMark(input.outcome, input.reason);
|
|
813
|
+
if (mark === void 0) return;
|
|
814
|
+
try {
|
|
815
|
+
const body = {
|
|
816
|
+
...mark,
|
|
817
|
+
reason: mark.reason ?? ""
|
|
818
|
+
};
|
|
819
|
+
await browserStackApiFor(input.options).markWebSession(input.sessionId, body, input.signal);
|
|
820
|
+
} catch (error) {
|
|
821
|
+
warn(`BrowserStack: could not mark Automate session ${input.sessionId} ${mark.status}: ${failureText(error)}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// src/plugin.ts
|
|
825
|
+
const HandleSchema = z.union([WebHandleSchema, DeviceHandleSchema]);
|
|
826
|
+
function createBrowserStackPlugin(input = {}) {
|
|
827
|
+
const tunnelFactory = input.tunnelFactory ?? browserStackLocalFactory;
|
|
828
|
+
let playwrightVersion = "unknown";
|
|
829
|
+
let tunnel;
|
|
830
|
+
const appSessions = /* @__PURE__ */ new Map();
|
|
831
|
+
const webSessions = /* @__PURE__ */ new Map();
|
|
832
|
+
return definePlugin({
|
|
833
|
+
options,
|
|
834
|
+
settings,
|
|
835
|
+
serves: ["playwright", "appium"],
|
|
836
|
+
runFacts: [
|
|
837
|
+
"testName",
|
|
838
|
+
"runId",
|
|
839
|
+
"failure"
|
|
840
|
+
],
|
|
841
|
+
skill: "./skill",
|
|
842
|
+
initialize({ options, host }) {
|
|
843
|
+
checkEndpoints(options);
|
|
844
|
+
checkLocal(options);
|
|
845
|
+
playwrightVersion = host.playwrightVersion;
|
|
846
|
+
tunnel = createTunnelManager({
|
|
847
|
+
factory: tunnelFactory,
|
|
848
|
+
accessKey: options.accessKey,
|
|
849
|
+
local: options.local,
|
|
850
|
+
localIdentifier: options.localIdentifier,
|
|
851
|
+
localBinary: options.localBinary
|
|
852
|
+
});
|
|
853
|
+
},
|
|
854
|
+
check({ options }, ctx) {
|
|
855
|
+
return checkAccount({
|
|
856
|
+
options,
|
|
857
|
+
playwrightVersion,
|
|
858
|
+
signal: ctx.signal
|
|
859
|
+
});
|
|
860
|
+
},
|
|
861
|
+
async acquire({ options, settings, session, run }, ctx) {
|
|
862
|
+
const localIdentifier = tunnel?.identifier;
|
|
863
|
+
const answer = session.platform === "web" ? playwrightAnswer({
|
|
864
|
+
options,
|
|
865
|
+
settings,
|
|
866
|
+
engine: session.engine,
|
|
867
|
+
playwrightVersion,
|
|
868
|
+
testName: run?.testName,
|
|
869
|
+
runId: ctx.runFacts?.runId,
|
|
870
|
+
localIdentifier
|
|
871
|
+
}) : deviceAnswer({
|
|
872
|
+
options,
|
|
873
|
+
settings,
|
|
874
|
+
platform: session.platform,
|
|
875
|
+
capabilities: session.capabilities,
|
|
876
|
+
testName: run?.testName,
|
|
877
|
+
runId: ctx.runFacts?.runId,
|
|
878
|
+
localIdentifier
|
|
879
|
+
});
|
|
880
|
+
await tunnel?.ensure(ctx.signal, ctx.progress);
|
|
881
|
+
return answer;
|
|
882
|
+
},
|
|
883
|
+
uploadApp({ options, path, sha256, size }, ctx) {
|
|
884
|
+
return uploadApp({
|
|
885
|
+
options,
|
|
886
|
+
path,
|
|
887
|
+
sha256,
|
|
888
|
+
size,
|
|
889
|
+
signal: ctx.signal,
|
|
890
|
+
progress: ctx.progress
|
|
891
|
+
});
|
|
892
|
+
},
|
|
893
|
+
async started({ options, handle, sessionId }, ctx) {
|
|
894
|
+
const web = WebHandleSchema.safeParse(handle);
|
|
895
|
+
if (web.success) {
|
|
896
|
+
const { session, link } = await webStarted({
|
|
897
|
+
options,
|
|
898
|
+
handle: web.data,
|
|
899
|
+
signal: ctx.signal
|
|
900
|
+
});
|
|
901
|
+
if (session !== void 0) webSessions.set(web.data.id, session.hashedId);
|
|
902
|
+
return link === void 0 ? {} : { link };
|
|
903
|
+
}
|
|
904
|
+
const device = DeviceHandleSchema.safeParse(handle);
|
|
905
|
+
if (!device.success) return {};
|
|
906
|
+
if (sessionId !== void 0) appSessions.set(device.data.id, sessionId);
|
|
907
|
+
return deviceStarted({
|
|
908
|
+
options,
|
|
909
|
+
handle: device.data,
|
|
910
|
+
sessionId,
|
|
911
|
+
signal: ctx.signal
|
|
912
|
+
});
|
|
913
|
+
},
|
|
914
|
+
async end({ options, handle, outcome, reason }, ctx) {
|
|
915
|
+
const web = WebHandleSchema.safeParse(handle);
|
|
916
|
+
if (web.success) {
|
|
917
|
+
const sessionId = webSessions.get(web.data.id);
|
|
918
|
+
if (sessionId === void 0) return;
|
|
919
|
+
await markWebSession({
|
|
920
|
+
options,
|
|
921
|
+
sessionId,
|
|
922
|
+
outcome,
|
|
923
|
+
reason,
|
|
924
|
+
signal: ctx.signal
|
|
925
|
+
});
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const device = DeviceHandleSchema.safeParse(handle);
|
|
929
|
+
if (!device.success) return;
|
|
930
|
+
const sessionId = appSessions.get(device.data.id);
|
|
931
|
+
if (sessionId === void 0) return;
|
|
932
|
+
await markAppSession({
|
|
933
|
+
options,
|
|
934
|
+
sessionId,
|
|
935
|
+
outcome,
|
|
936
|
+
reason,
|
|
937
|
+
signal: ctx.signal
|
|
938
|
+
});
|
|
939
|
+
},
|
|
940
|
+
release({ handle }) {
|
|
941
|
+
const parsed = HandleSchema.safeParse(handle);
|
|
942
|
+
if (!parsed.success) throw new PluginError("A BrowserStack handle names its kind of session.", { kind: "refused" });
|
|
943
|
+
if (parsed.data.kind === "device") appSessions.delete(parsed.data.id);
|
|
944
|
+
else webSessions.delete(parsed.data.id);
|
|
945
|
+
},
|
|
946
|
+
async shutdown() {
|
|
947
|
+
await tunnel?.stop();
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
var plugin_default = createBrowserStackPlugin();
|
|
952
|
+
export { plugin_default as n, createBrowserStackPlugin as t };
|
|
953
|
+
|
|
954
|
+
//# sourceMappingURL=plugin-fBvi25yB.js.map
|