Skip to content

Commit 6512110

Browse files
committed
browserstack-local run-with npm script
Issue with using start/stop in npm pre/post scripts is if the target npm command errors out, the binary isn't stopped since it runs in daemon mode. If we (cmd || browserstack-local stop), the proper exit code isn't propagated for the CI run to be marked as failed. run-with wraps the npm test command and copies over the child process exit code. TODO: test cross-platform
1 parent ca1e0a5 commit 6512110

File tree

1 file changed

+90
-6
lines changed

1 file changed

+90
-6
lines changed

src/cli/browserstack-local.ts

+90-6
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ import { BrowserStackError } from "@/error";
55
import { ensureDirExists } from "@/fs-utils";
66
import { BrowserStack, LocalTestingBinaryOptions } from "@/index.node";
77
import { writeFileAtomic } from "@/write-file-atomic";
8+
import cp from "node:child_process";
89
import { readFile } from "node:fs/promises";
910
import { createRequire } from "node:module";
1011
import { homedir, tmpdir } from "node:os";
1112
import { join } from "node:path";
13+
import { onExit } from "signal-exit";
1214

1315
const require = createRequire(import.meta.url);
1416

1517
enum BrowserStackLocalAction {
1618
start = "start",
1719
stop = "stop",
1820
list = "list",
21+
runWith = "run-with",
1922
}
2023

2124
interface Logger {
@@ -67,6 +70,8 @@ async function start(
6770
await writeStatusFile(statusPath, localIdentifiers, entries);
6871
logger.info(`${localIdentifier}: ${status}`);
6972
}
73+
74+
return localIdentifier;
7075
}
7176

7277
async function stopInstance(
@@ -156,9 +161,61 @@ async function stop(
156161
*/
157162
async function list(statusPath: string, logger: Logger = globalThis.console) {
158163
const { localIdentifiers = [] } = await readOrCreateStatusFile(statusPath);
164+
localIdentifiers.forEach((localIdentifier) => logger.info(localIdentifier));
165+
}
166+
167+
/**
168+
* Runs the local testing binary before a user-provided command and stops it after the command has finished.
169+
* Also sets the BROWSERSTACK_LOCAL_ID and BROWSERSTACK_LOCAL_IDENTIFIER environment variables.
170+
*
171+
* @param options - The options for running the local testing binary.
172+
* @param statusPath - The path to the status file.
173+
* @param runWithArgs - The arguments to run the local testing binary with.
174+
* @param logger - The logger instance.
175+
*/
176+
async function runWith(
177+
options: LocalTestingBinaryOptions,
178+
statusPath: string,
179+
runWithArgs: string[],
180+
logger: Logger
181+
) {
182+
const localIdentifier = await start(options, statusPath, logger);
183+
let childExitCode: number;
184+
185+
const exitHandler = async (code?: number | null) => {
186+
await stop({ ...options, localIdentifier }, statusPath, logger);
187+
process.exit(typeof code === "number" ? code : childExitCode);
188+
};
189+
190+
const removeOnExitHandler = onExit(() => {
191+
(async () => await exitHandler())();
192+
});
193+
194+
try {
195+
const [cmd, ...args] = runWithArgs;
196+
const childProcess = cp.spawnSync(cmd, args, {
197+
cwd: process.cwd(),
198+
stdio: "inherit",
199+
windowsHide: true,
200+
env: {
201+
...process.env,
202+
BROWSERSTACK_LOCAL_ID: localIdentifier,
203+
BROWSERSTACK_LOCAL_IDENTIFIER: localIdentifier,
204+
},
205+
});
159206

160-
for (const localIdentifier of localIdentifiers) {
161-
logger.info(localIdentifier);
207+
childExitCode = childProcess.status ?? (childProcess.error ? 1 : 0);
208+
} catch (err) {
209+
childExitCode = 1;
210+
211+
if (err instanceof Error) {
212+
logger.error(err?.message);
213+
} else {
214+
logger.error(`An unexpected error occurred: ${err}`);
215+
}
216+
} finally {
217+
removeOnExitHandler();
218+
await exitHandler();
162219
}
163220
}
164221

@@ -334,19 +391,23 @@ async function writeStatusFile(
334391
null,
335392
2
336393
),
337-
{ encoding: fileEncoding },
394+
{ encoding: fileEncoding }
338395
);
339396
}
340397

341398
export async function main(
342399
inputArgs: string[] = process.argv.slice(2),
343-
logger: Logger = globalThis.console
400+
logger: Logger = globalThis.console,
401+
cmdSeparator: string = "--"
344402
) {
345403
try {
346404
const args = inputArgs.map((arg) => arg.trim());
347405
const action = ensureValidAction(args[0]);
348-
const localIdentifier = resolveEnvLocalIdentifier() ?? args[1];
349-
const key = ensureKeyExists(args[2]);
406+
const localIdentifier =
407+
resolveEnvLocalIdentifier() ??
408+
(args[1] === cmdSeparator ? undefined : args[1]);
409+
410+
const key = ensureKeyExists(args[2] === cmdSeparator ? undefined : args[2]);
350411
const binHome = await ensureBinHomeExists();
351412
const statusPath = join(binHome, "status.json");
352413

@@ -363,6 +424,29 @@ export async function main(
363424
await list(statusPath, logger);
364425
break;
365426
}
427+
case BrowserStackLocalAction.runWith: {
428+
const cmdStartIndex = args.findIndex((arg) => arg === cmdSeparator);
429+
if (cmdStartIndex === -1) {
430+
throw new BrowserStackError(
431+
`Invalid run-with command: no command separator ${cmdSeparator} found`
432+
);
433+
}
434+
435+
const runWithArgs = args.slice(cmdStartIndex + 1);
436+
if (!runWithArgs.length) {
437+
throw new BrowserStackError(
438+
"Invalid run-with command: no command provided"
439+
);
440+
}
441+
442+
// resolves and exits by itself
443+
return await runWith(
444+
{ key, binHome, localIdentifier },
445+
statusPath,
446+
runWithArgs,
447+
logger
448+
);
449+
}
366450
default:
367451
throw new BrowserStackError(
368452
`Invalid action: ${action} (valid actions: start, stop, list)`

0 commit comments

Comments
 (0)