@test-bro/cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -14
- package/dist/index.js +409 -82
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -7,18 +7,17 @@ Command-line interface for AI-powered browser testing. Generate, run, and manage
|
|
|
7
7
|
### Global Installation (Recommended)
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install -g
|
|
10
|
+
npm install -g @test-bro/cli
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
### Local Development
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
17
|
-
pnpm install
|
|
18
|
-
pnpm build
|
|
19
|
-
pnpm link --global
|
|
16
|
+
npm install -g @test-bro/cli
|
|
20
17
|
```
|
|
21
18
|
|
|
19
|
+
For local CLI validation workflows (unit tests, dist smoke tests, and `npm pack` artifact testing), see [`local-cli-testing.md`](./local-cli-testing.md).
|
|
20
|
+
|
|
22
21
|
## Quick Start
|
|
23
22
|
|
|
24
23
|
### 1. Authenticate
|
|
@@ -143,12 +142,12 @@ Authenticate with TestBro API.
|
|
|
143
142
|
testbro login --token tb_live_xxxxx
|
|
144
143
|
|
|
145
144
|
# Custom API URL
|
|
146
|
-
testbro login --token tb_live_xxxxx --url https://
|
|
145
|
+
testbro login --token tb_live_xxxxx --url https://testbro.dev
|
|
147
146
|
```
|
|
148
147
|
|
|
149
148
|
**Options:**
|
|
150
149
|
- `-t, --token <token>`: API token from your TestBro account
|
|
151
|
-
- `-u, --url <url>`: API URL (default:
|
|
150
|
+
- `-u, --url <url>`: API URL (default: https://testbro.dev)
|
|
152
151
|
|
|
153
152
|
#### `testbro logout`
|
|
154
153
|
|
|
@@ -405,7 +404,7 @@ TestBro Configuration
|
|
|
405
404
|
|
|
406
405
|
User Config (~/.testbro/config.json)
|
|
407
406
|
Path: /Users/name/.testbro/config.json
|
|
408
|
-
API URL:
|
|
407
|
+
API URL: https://testbro.dev
|
|
409
408
|
Authenticated: Yes
|
|
410
409
|
Active Project: proj_abc123
|
|
411
410
|
|
|
@@ -465,7 +464,7 @@ Stores user-level settings and authentication.
|
|
|
465
464
|
|
|
466
465
|
```json
|
|
467
466
|
{
|
|
468
|
-
"apiUrl": "
|
|
467
|
+
"apiUrl": "https://testbro.dev",
|
|
469
468
|
"token": "tb_live_xxxxx",
|
|
470
469
|
"activeProjectId": "proj_abc123"
|
|
471
470
|
}
|
|
@@ -530,7 +529,7 @@ TEST_USERNAME=testuser@example.com
|
|
|
530
529
|
TEST_PASSWORD=SecureP@ssw0rd
|
|
531
530
|
|
|
532
531
|
# API URL override
|
|
533
|
-
TESTBRO_API_URL=https://
|
|
532
|
+
TESTBRO_API_URL=https://testbro.dev
|
|
534
533
|
```
|
|
535
534
|
|
|
536
535
|
## Session Continuity
|
|
@@ -686,7 +685,7 @@ jobs:
|
|
|
686
685
|
node-version: 18
|
|
687
686
|
|
|
688
687
|
- name: Install TestBro CLI
|
|
689
|
-
run: npm install -g
|
|
688
|
+
run: npm install -g @test-bro/cli
|
|
690
689
|
|
|
691
690
|
- name: Run Tests
|
|
692
691
|
env:
|
|
@@ -708,7 +707,7 @@ jobs:
|
|
|
708
707
|
test:
|
|
709
708
|
image: node:18
|
|
710
709
|
script:
|
|
711
|
-
- npm install -g
|
|
710
|
+
- npm install -g @test-bro/cli
|
|
712
711
|
- testbro run --project $PROJECT_ID --format json > results.json
|
|
713
712
|
artifacts:
|
|
714
713
|
paths:
|
|
@@ -830,8 +829,8 @@ pnpm install
|
|
|
830
829
|
# Build
|
|
831
830
|
pnpm build
|
|
832
831
|
|
|
833
|
-
#
|
|
834
|
-
|
|
832
|
+
# Install official CLI globally
|
|
833
|
+
npm install -g @test-bro/cli
|
|
835
834
|
|
|
836
835
|
# Run locally
|
|
837
836
|
pnpm start -- run --help
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
#!/usr/bin/env node
|
|
3
2
|
var __defProp = Object.defineProperty;
|
|
4
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -181,7 +180,7 @@ var init_config = __esm({
|
|
|
181
180
|
"src/config.ts"() {
|
|
182
181
|
"use strict";
|
|
183
182
|
DEFAULT_CONFIG = {
|
|
184
|
-
apiUrl: "https://
|
|
183
|
+
apiUrl: "https://testbro.dev"
|
|
185
184
|
};
|
|
186
185
|
}
|
|
187
186
|
});
|
|
@@ -339,7 +338,7 @@ function mergeWithCliOptions(projectConfig, cliOptions) {
|
|
|
339
338
|
return merged;
|
|
340
339
|
}
|
|
341
340
|
function getProjectConfigSummary() {
|
|
342
|
-
const { config, path:
|
|
341
|
+
const { config, path: path10, error } = loadProjectConfig();
|
|
343
342
|
let tokenSource = null;
|
|
344
343
|
let token = null;
|
|
345
344
|
if (process.env.TEST_BRO_TOKEN) {
|
|
@@ -357,7 +356,7 @@ function getProjectConfigSummary() {
|
|
|
357
356
|
}
|
|
358
357
|
}
|
|
359
358
|
return {
|
|
360
|
-
projectConfigPath:
|
|
359
|
+
projectConfigPath: path10,
|
|
361
360
|
projectConfig: config,
|
|
362
361
|
error,
|
|
363
362
|
token,
|
|
@@ -393,7 +392,8 @@ var init_project_config = __esm({
|
|
|
393
392
|
steps: z.array(authStepSchema),
|
|
394
393
|
credentials: z.record(z.string(), z.string()).optional(),
|
|
395
394
|
storageStatePath: z.string().optional(),
|
|
396
|
-
maxAge: z.number().optional()
|
|
395
|
+
maxAge: z.number().optional(),
|
|
396
|
+
waitForUrl: z.string().optional()
|
|
397
397
|
});
|
|
398
398
|
projectConfigSchema = z.object({
|
|
399
399
|
$schema: z.string().optional(),
|
|
@@ -439,7 +439,7 @@ var init_project_config = __esm({
|
|
|
439
439
|
// src/index.ts
|
|
440
440
|
import "dotenv/config";
|
|
441
441
|
import { Command } from "commander";
|
|
442
|
-
import
|
|
442
|
+
import chalk29 from "chalk";
|
|
443
443
|
|
|
444
444
|
// src/commands/login.ts
|
|
445
445
|
import chalk3 from "chalk";
|
|
@@ -616,6 +616,7 @@ async function apiRequestCore(endpoint, options = {}, authToken, requireAuth = t
|
|
|
616
616
|
};
|
|
617
617
|
if (token) {
|
|
618
618
|
headers.Authorization = `Bearer ${token}`;
|
|
619
|
+
headers["x-api-key"] = token;
|
|
619
620
|
}
|
|
620
621
|
const response = await fetch(url, {
|
|
621
622
|
...options,
|
|
@@ -816,40 +817,50 @@ async function uploadArtifacts(runId, options) {
|
|
|
816
817
|
}
|
|
817
818
|
const token = getToken();
|
|
818
819
|
if (!token) {
|
|
819
|
-
|
|
820
|
+
throw new Error("No API token found for artifact upload");
|
|
820
821
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
822
|
+
const apiUrl = getApiUrl();
|
|
823
|
+
const url = `${apiUrl}/api/runs/${runId}/artifacts`;
|
|
824
|
+
const formData = new FormData();
|
|
825
|
+
if (videoPath) {
|
|
826
|
+
const videoBuffer = fs2.readFileSync(videoPath);
|
|
827
|
+
const videoType = videoPath.endsWith(".mp4") ? "video/mp4" : "video/webm";
|
|
828
|
+
const videoBlob = new Blob([videoBuffer], { type: videoType });
|
|
829
|
+
const videoFileName = videoPath.split("/").pop() || "video.webm";
|
|
830
|
+
formData.append("video", videoBlob, videoFileName);
|
|
831
|
+
}
|
|
832
|
+
if (tracePath) {
|
|
833
|
+
const traceBuffer = fs2.readFileSync(tracePath);
|
|
834
|
+
const traceBlob = new Blob([traceBuffer], { type: "application/zip" });
|
|
835
|
+
const traceFileName = tracePath.split("/").pop() || "trace.zip";
|
|
836
|
+
formData.append("trace", traceBlob, traceFileName);
|
|
837
|
+
}
|
|
838
|
+
const response = await fetch(url, {
|
|
839
|
+
method: "POST",
|
|
840
|
+
headers: {
|
|
841
|
+
Authorization: `Bearer ${token}`
|
|
842
|
+
},
|
|
843
|
+
body: formData
|
|
844
|
+
});
|
|
845
|
+
if (!response.ok) {
|
|
846
|
+
let details = "";
|
|
847
|
+
try {
|
|
848
|
+
const contentType = response.headers.get("content-type") || "";
|
|
849
|
+
if (contentType.includes("application/json")) {
|
|
850
|
+
const errData = await response.json();
|
|
851
|
+
details = errData.error || JSON.stringify(errData.details || errData.data || "");
|
|
852
|
+
} else {
|
|
853
|
+
details = await response.text();
|
|
854
|
+
}
|
|
855
|
+
} catch {
|
|
856
|
+
details = "";
|
|
847
857
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
return {};
|
|
858
|
+
throw new Error(
|
|
859
|
+
`Artifact upload failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${details ? `: ${details}` : ""}`
|
|
860
|
+
);
|
|
852
861
|
}
|
|
862
|
+
const data = await response.json();
|
|
863
|
+
return data.data || {};
|
|
853
864
|
}
|
|
854
865
|
async function listFeatures(projectId) {
|
|
855
866
|
return apiRequest(`/api/projects/${projectId}/features`);
|
|
@@ -1034,10 +1045,10 @@ function extractValidationHints(details) {
|
|
|
1034
1045
|
for (const issue of obj.issues) {
|
|
1035
1046
|
if (typeof issue === "object" && issue !== null) {
|
|
1036
1047
|
const i = issue;
|
|
1037
|
-
const
|
|
1048
|
+
const path10 = Array.isArray(i.path) ? i.path.join(".") : String(i.path ?? "");
|
|
1038
1049
|
const message = typeof i.message === "string" ? i.message : "";
|
|
1039
|
-
if (
|
|
1040
|
-
hints.push(`${
|
|
1050
|
+
if (path10 && message) {
|
|
1051
|
+
hints.push(`${path10}: ${message}`);
|
|
1041
1052
|
} else if (message) {
|
|
1042
1053
|
hints.push(message);
|
|
1043
1054
|
}
|
|
@@ -1419,7 +1430,7 @@ async function quickstartCommand(options = {}) {
|
|
|
1419
1430
|
console.log("");
|
|
1420
1431
|
console.log(chalk5.yellow(" Please authenticate first:"));
|
|
1421
1432
|
console.log("");
|
|
1422
|
-
console.log(chalk5.dim(" 1. Go to ") + chalk5.cyan("
|
|
1433
|
+
console.log(chalk5.dim(" 1. Go to ") + chalk5.cyan("https://testbro.dev/dashboard/settings"));
|
|
1423
1434
|
console.log(chalk5.dim(" 2. Create an API token"));
|
|
1424
1435
|
console.log(chalk5.dim(" 3. Run: ") + chalk5.cyan("testbro init") + chalk5.dim(" (or testbro login --token <your-token>)"));
|
|
1425
1436
|
console.log("");
|
|
@@ -1434,7 +1445,7 @@ async function quickstartCommand(options = {}) {
|
|
|
1434
1445
|
spinner.fail("Token is invalid or expired");
|
|
1435
1446
|
console.log("");
|
|
1436
1447
|
console.log(chalk5.yellow(" Please re-authenticate:"));
|
|
1437
|
-
console.log(chalk5.dim(" 1. Go to ") + chalk5.cyan("
|
|
1448
|
+
console.log(chalk5.dim(" 1. Go to ") + chalk5.cyan("https://testbro.dev/dashboard/settings"));
|
|
1438
1449
|
console.log(chalk5.dim(" 2. Create a new API token"));
|
|
1439
1450
|
console.log(chalk5.dim(" 3. Run: ") + chalk5.cyan("testbro init") + chalk5.dim(" (or testbro login --token <your-token>)"));
|
|
1440
1451
|
console.log("");
|
|
@@ -1875,7 +1886,7 @@ var AuthExecutor = class _AuthExecutor {
|
|
|
1875
1886
|
);
|
|
1876
1887
|
await _AuthExecutor.executeStep(page, resolvedStep);
|
|
1877
1888
|
}
|
|
1878
|
-
await
|
|
1889
|
+
await _AuthExecutor.waitForAuthComplete(page, authConfig, loginUrl);
|
|
1879
1890
|
await context.storageState({ path: statePath });
|
|
1880
1891
|
return statePath;
|
|
1881
1892
|
} finally {
|
|
@@ -2034,6 +2045,42 @@ var AuthExecutor = class _AuthExecutor {
|
|
|
2034
2045
|
}
|
|
2035
2046
|
}
|
|
2036
2047
|
}
|
|
2048
|
+
/**
|
|
2049
|
+
* Wait for the authentication flow to fully complete after all steps.
|
|
2050
|
+
*
|
|
2051
|
+
* Handles redirect-based auth flows (e.g., NextAuth v5 server actions)
|
|
2052
|
+
* where the session cookie is set during a redirect chain, not on the
|
|
2053
|
+
* initial response. Uses three strategies in order:
|
|
2054
|
+
*
|
|
2055
|
+
* 1. If `waitForUrl` is configured, wait for that specific URL pattern
|
|
2056
|
+
* 2. Otherwise, detect URL change from the login page (redirect completed)
|
|
2057
|
+
* 3. Wait for network idle to ensure all Set-Cookie headers are processed
|
|
2058
|
+
*/
|
|
2059
|
+
static async waitForAuthComplete(page, authConfig, loginUrl) {
|
|
2060
|
+
const waitTimeout = 15e3;
|
|
2061
|
+
if (authConfig.waitForUrl) {
|
|
2062
|
+
try {
|
|
2063
|
+
await page.waitForURL(authConfig.waitForUrl, {
|
|
2064
|
+
timeout: waitTimeout,
|
|
2065
|
+
waitUntil: "load"
|
|
2066
|
+
});
|
|
2067
|
+
} catch {
|
|
2068
|
+
}
|
|
2069
|
+
} else {
|
|
2070
|
+
try {
|
|
2071
|
+
await page.waitForURL(
|
|
2072
|
+
(url) => url.toString() !== loginUrl,
|
|
2073
|
+
{ timeout: waitTimeout, waitUntil: "load" }
|
|
2074
|
+
);
|
|
2075
|
+
} catch {
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
try {
|
|
2079
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 });
|
|
2080
|
+
} catch {
|
|
2081
|
+
}
|
|
2082
|
+
await page.waitForTimeout(1e3);
|
|
2083
|
+
}
|
|
2037
2084
|
/**
|
|
2038
2085
|
* Delete the cached storage state file.
|
|
2039
2086
|
* @param statePath - Path to the storage state file
|
|
@@ -3434,6 +3481,17 @@ You are a GOAL-ORIENTED test agent. Each step describes an INTENT, not a rigid i
|
|
|
3434
3481
|
- Always explain your reasoning when deviating from the original step description
|
|
3435
3482
|
- If an element is below the fold, suggest scrolling first
|
|
3436
3483
|
|
|
3484
|
+
## Intent Safety Guardrails
|
|
3485
|
+
- NEVER click or fill a semantically unrelated element just to make progress.
|
|
3486
|
+
- If the requested target is not present, do NOT guess a random alternative action.
|
|
3487
|
+
- Preserve intent class when adapting. Examples:
|
|
3488
|
+
- form input intent -> editable field
|
|
3489
|
+
- navigation intent -> relevant nav control/link
|
|
3490
|
+
- confirmation intent -> intended submit/confirm action
|
|
3491
|
+
- Prefer this fallback order when target is missing: **scroll -> wait -> navigate/click a clearly relevant entry point for the SAME intent class**.
|
|
3492
|
+
- Before acting, validate evidence from Available Elements and visible UI cues (role, label, testId, text). If evidence is weak or ambiguous, lower confidence or choose wait/scroll instead of a risky click.
|
|
3493
|
+
- If no relevant action is possible on the current page, return confidence 0 and explain why.
|
|
3494
|
+
|
|
3437
3495
|
Always respond with valid JSON only. Do not include any other text.`;
|
|
3438
3496
|
var MAX_MEMORY_ENTRIES = 10;
|
|
3439
3497
|
function formatStepMemory(memory) {
|
|
@@ -3497,7 +3555,9 @@ function generateFillPrompt(fieldDescription, textToEnter, availableElements, me
|
|
|
3497
3555
|
Then type: "${textToEnter}"`,
|
|
3498
3556
|
`IMPORTANT: For fill actions, you MUST target an <input>, <textarea>, or <select> element \u2014 NOT a link, button, div, or card.
|
|
3499
3557
|
If a dialog or modal is open, look for input fields INSIDE the dialog, not behind it.
|
|
3500
|
-
Identify the input field and provide coordinates to click it before typing. Look for data-testid, name, or placeholder attributes for the best selector
|
|
3558
|
+
Identify the input field and provide coordinates to click it before typing. Look for data-testid, name, or placeholder attributes for the best selector.
|
|
3559
|
+
If no editable field for this intent is visible, do NOT fill a non-input element.
|
|
3560
|
+
If input is unavailable in current state, choose a preparatory step that keeps the same intent class (e.g., open relevant form/screen), otherwise return confidence 0.`,
|
|
3501
3561
|
availableElements,
|
|
3502
3562
|
memoryContext
|
|
3503
3563
|
);
|
|
@@ -3571,6 +3631,9 @@ Consider:
|
|
|
3571
3631
|
- You may need to scroll to find the element
|
|
3572
3632
|
- A modal or overlay may be blocking \u2014 close it first
|
|
3573
3633
|
- The page may be in a different state than expected \u2014 adapt accordingly
|
|
3634
|
+
- Preserve intent class when adapting (input -> input, navigation -> navigation, assertion -> evidence)
|
|
3635
|
+
- Prefer preparatory actions (scroll/wait/open relevant surface) before risky substitutions
|
|
3636
|
+
- NEVER use an unrelated element as a substitute action
|
|
3574
3637
|
- If the intent is truly impossible on this page, set confidence to 0
|
|
3575
3638
|
|
|
3576
3639
|
Respond with the action you would take to achieve the intent.`
|
|
@@ -8349,28 +8412,42 @@ async function runTestExecution(options) {
|
|
|
8349
8412
|
if (isPretty) {
|
|
8350
8413
|
if (uploadResult.videoUrl) {
|
|
8351
8414
|
log.plain(`${chalk16.bold("Video URL:")} ${chalk16.cyan(uploadResult.videoUrl)}`);
|
|
8415
|
+
} else if (videoPath) {
|
|
8416
|
+
log.warn("Video upload completed without a Video URL. Keeping local file.");
|
|
8417
|
+
log.dim(`Local video: ${videoPath}`);
|
|
8352
8418
|
}
|
|
8353
8419
|
if (uploadResult.traceUrl) {
|
|
8354
8420
|
log.plain(`${chalk16.bold("Trace URL:")} ${chalk16.cyan(uploadResult.traceUrl)}`);
|
|
8421
|
+
} else if (tracePath) {
|
|
8422
|
+
log.warn("Trace upload completed without a Trace URL. Keeping local file.");
|
|
8423
|
+
log.dim(`Local trace: ${tracePath}`);
|
|
8355
8424
|
}
|
|
8356
8425
|
}
|
|
8357
|
-
if (videoPath) {
|
|
8426
|
+
if (videoPath && uploadResult.videoUrl) {
|
|
8358
8427
|
try {
|
|
8359
8428
|
fs7.unlinkSync(videoPath);
|
|
8360
8429
|
fs7.rmSync(path7.dirname(videoPath), { recursive: true, force: true });
|
|
8361
8430
|
} catch {
|
|
8362
8431
|
}
|
|
8363
8432
|
}
|
|
8364
|
-
if (tracePath) {
|
|
8433
|
+
if (tracePath && uploadResult.traceUrl) {
|
|
8365
8434
|
try {
|
|
8366
8435
|
fs7.unlinkSync(tracePath);
|
|
8367
8436
|
fs7.rmSync(path7.dirname(tracePath), { recursive: true, force: true });
|
|
8368
8437
|
} catch {
|
|
8369
8438
|
}
|
|
8370
8439
|
}
|
|
8371
|
-
} catch {
|
|
8440
|
+
} catch (error) {
|
|
8372
8441
|
if (isPretty) {
|
|
8373
|
-
|
|
8442
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8443
|
+
log.warn("Failed to upload recordings");
|
|
8444
|
+
log.dim(`Reason: ${message}`);
|
|
8445
|
+
if (videoPath) {
|
|
8446
|
+
log.dim(`Local video kept: ${videoPath}`);
|
|
8447
|
+
}
|
|
8448
|
+
if (tracePath) {
|
|
8449
|
+
log.dim(`Local trace kept: ${tracePath}`);
|
|
8450
|
+
}
|
|
8374
8451
|
}
|
|
8375
8452
|
}
|
|
8376
8453
|
}
|
|
@@ -9879,10 +9956,245 @@ function formatStatus2(status) {
|
|
|
9879
9956
|
}
|
|
9880
9957
|
}
|
|
9881
9958
|
|
|
9959
|
+
// src/commands/upgrade.ts
|
|
9960
|
+
import { execSync } from "child_process";
|
|
9961
|
+
import chalk28 from "chalk";
|
|
9962
|
+
|
|
9963
|
+
// package.json
|
|
9964
|
+
var package_default = {
|
|
9965
|
+
name: "@test-bro/cli",
|
|
9966
|
+
publishConfig: {
|
|
9967
|
+
access: "public"
|
|
9968
|
+
},
|
|
9969
|
+
version: "0.1.1",
|
|
9970
|
+
description: "TestBro CLI - AI-powered browser testing from your terminal",
|
|
9971
|
+
type: "module",
|
|
9972
|
+
bin: {
|
|
9973
|
+
testbro: "dist/index.js"
|
|
9974
|
+
},
|
|
9975
|
+
files: [
|
|
9976
|
+
"dist"
|
|
9977
|
+
],
|
|
9978
|
+
engines: {
|
|
9979
|
+
node: ">=18"
|
|
9980
|
+
},
|
|
9981
|
+
keywords: [
|
|
9982
|
+
"testing",
|
|
9983
|
+
"browser-testing",
|
|
9984
|
+
"playwright",
|
|
9985
|
+
"ai-testing",
|
|
9986
|
+
"test-automation",
|
|
9987
|
+
"e2e",
|
|
9988
|
+
"qa"
|
|
9989
|
+
],
|
|
9990
|
+
repository: {
|
|
9991
|
+
type: "git",
|
|
9992
|
+
url: "git+https://github.com/chernobelenkiy/test-bro.git",
|
|
9993
|
+
directory: "apps/cli"
|
|
9994
|
+
},
|
|
9995
|
+
license: "MIT",
|
|
9996
|
+
scripts: {
|
|
9997
|
+
build: "tsup",
|
|
9998
|
+
dev: "tsup --watch",
|
|
9999
|
+
typecheck: "tsc --noEmit",
|
|
10000
|
+
lint: "eslint src/",
|
|
10001
|
+
test: "vitest run",
|
|
10002
|
+
"test:watch": "vitest",
|
|
10003
|
+
"test:coverage": "vitest run --coverage"
|
|
10004
|
+
},
|
|
10005
|
+
dependencies: {
|
|
10006
|
+
commander: "^12.1.0",
|
|
10007
|
+
chalk: "^5.3.0",
|
|
10008
|
+
ora: "^8.1.1",
|
|
10009
|
+
dotenv: "^16.4.7",
|
|
10010
|
+
playwright: "^1.40.0",
|
|
10011
|
+
zod: "^3.24.1"
|
|
10012
|
+
},
|
|
10013
|
+
devDependencies: {
|
|
10014
|
+
"@testbro/core": "workspace:*",
|
|
10015
|
+
"@testbro/runner": "workspace:*",
|
|
10016
|
+
"@testbro/ai-agent": "workspace:*",
|
|
10017
|
+
"@types/node": "^22.10.2",
|
|
10018
|
+
"@vitest/coverage-v8": "^2.1.0",
|
|
10019
|
+
tsup: "^8.3.5",
|
|
10020
|
+
typescript: "^5.7.2",
|
|
10021
|
+
vitest: "^2.1.0"
|
|
10022
|
+
}
|
|
10023
|
+
};
|
|
10024
|
+
|
|
10025
|
+
// src/version.ts
|
|
10026
|
+
var CLI_VERSION = package_default.version;
|
|
10027
|
+
|
|
10028
|
+
// src/version-check.ts
|
|
10029
|
+
init_config();
|
|
10030
|
+
import * as fs10 from "fs";
|
|
10031
|
+
import * as path9 from "path";
|
|
10032
|
+
var CACHE_FILE_NAME = "version-check.json";
|
|
10033
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
10034
|
+
var NPM_REGISTRY_URL = "https://registry.npmjs.org/@test-bro/cli/latest";
|
|
10035
|
+
var FETCH_TIMEOUT_MS = 5e3;
|
|
10036
|
+
function getCachePath() {
|
|
10037
|
+
return path9.join(getConfigDir(), CACHE_FILE_NAME);
|
|
10038
|
+
}
|
|
10039
|
+
function readCache() {
|
|
10040
|
+
try {
|
|
10041
|
+
const cachePath = getCachePath();
|
|
10042
|
+
if (!fs10.existsSync(cachePath)) {
|
|
10043
|
+
return null;
|
|
10044
|
+
}
|
|
10045
|
+
const raw = fs10.readFileSync(cachePath, "utf-8");
|
|
10046
|
+
const data = JSON.parse(raw);
|
|
10047
|
+
const age = Date.now() - new Date(data.checkedAt).getTime();
|
|
10048
|
+
if (age > CACHE_TTL_MS) {
|
|
10049
|
+
return null;
|
|
10050
|
+
}
|
|
10051
|
+
return data;
|
|
10052
|
+
} catch {
|
|
10053
|
+
return null;
|
|
10054
|
+
}
|
|
10055
|
+
}
|
|
10056
|
+
function writeCache(latestVersion) {
|
|
10057
|
+
try {
|
|
10058
|
+
const configDir = getConfigDir();
|
|
10059
|
+
if (!fs10.existsSync(configDir)) {
|
|
10060
|
+
fs10.mkdirSync(configDir, { recursive: true, mode: 448 });
|
|
10061
|
+
}
|
|
10062
|
+
const data = {
|
|
10063
|
+
latestVersion,
|
|
10064
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
10065
|
+
};
|
|
10066
|
+
fs10.writeFileSync(getCachePath(), JSON.stringify(data, null, 2), {
|
|
10067
|
+
encoding: "utf-8",
|
|
10068
|
+
mode: 384
|
|
10069
|
+
});
|
|
10070
|
+
} catch {
|
|
10071
|
+
}
|
|
10072
|
+
}
|
|
10073
|
+
async function fetchLatestVersion() {
|
|
10074
|
+
try {
|
|
10075
|
+
const controller = new AbortController();
|
|
10076
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
10077
|
+
const response = await fetch(NPM_REGISTRY_URL, {
|
|
10078
|
+
signal: controller.signal,
|
|
10079
|
+
headers: { Accept: "application/json" }
|
|
10080
|
+
});
|
|
10081
|
+
clearTimeout(timeout);
|
|
10082
|
+
if (!response.ok) {
|
|
10083
|
+
return null;
|
|
10084
|
+
}
|
|
10085
|
+
const data = await response.json();
|
|
10086
|
+
return data.version ?? null;
|
|
10087
|
+
} catch {
|
|
10088
|
+
return null;
|
|
10089
|
+
}
|
|
10090
|
+
}
|
|
10091
|
+
function compareSemver(a, b) {
|
|
10092
|
+
const pa = a.split(".").map(Number);
|
|
10093
|
+
const pb = b.split(".").map(Number);
|
|
10094
|
+
const len = Math.max(pa.length, pb.length);
|
|
10095
|
+
for (let i = 0; i < len; i++) {
|
|
10096
|
+
const na = pa[i] ?? 0;
|
|
10097
|
+
const nb = pb[i] ?? 0;
|
|
10098
|
+
if (na < nb) return -1;
|
|
10099
|
+
if (na > nb) return 1;
|
|
10100
|
+
}
|
|
10101
|
+
return 0;
|
|
10102
|
+
}
|
|
10103
|
+
async function checkForUpdate(currentVersion) {
|
|
10104
|
+
const cached = readCache();
|
|
10105
|
+
if (cached) {
|
|
10106
|
+
if (compareSemver(currentVersion, cached.latestVersion) < 0) {
|
|
10107
|
+
return cached.latestVersion;
|
|
10108
|
+
}
|
|
10109
|
+
return null;
|
|
10110
|
+
}
|
|
10111
|
+
const latest = await fetchLatestVersion();
|
|
10112
|
+
if (!latest) {
|
|
10113
|
+
return null;
|
|
10114
|
+
}
|
|
10115
|
+
writeCache(latest);
|
|
10116
|
+
if (compareSemver(currentVersion, latest) < 0) {
|
|
10117
|
+
return latest;
|
|
10118
|
+
}
|
|
10119
|
+
return null;
|
|
10120
|
+
}
|
|
10121
|
+
function startVersionCheck(currentVersion) {
|
|
10122
|
+
const checkPromise = checkForUpdate(currentVersion).catch(() => null);
|
|
10123
|
+
return async () => {
|
|
10124
|
+
try {
|
|
10125
|
+
const latestVersion = await checkPromise;
|
|
10126
|
+
if (latestVersion) {
|
|
10127
|
+
const chalk30 = (await import("chalk")).default;
|
|
10128
|
+
console.log(
|
|
10129
|
+
chalk30.yellow(
|
|
10130
|
+
`
|
|
10131
|
+
Update available: ${currentVersion} \u2192 ${latestVersion}. Run \`testbro upgrade\` to update.`
|
|
10132
|
+
)
|
|
10133
|
+
);
|
|
10134
|
+
}
|
|
10135
|
+
} catch {
|
|
10136
|
+
}
|
|
10137
|
+
};
|
|
10138
|
+
}
|
|
10139
|
+
|
|
10140
|
+
// src/commands/upgrade.ts
|
|
10141
|
+
async function upgradeCommand() {
|
|
10142
|
+
console.log(chalk28.bold("TestBro CLI Upgrade"));
|
|
10143
|
+
console.log("");
|
|
10144
|
+
console.log(` Current version: ${chalk28.dim(CLI_VERSION)}`);
|
|
10145
|
+
console.log(chalk28.dim(" Checking npm registry..."));
|
|
10146
|
+
const latestVersion = await fetchLatestVersion();
|
|
10147
|
+
if (!latestVersion) {
|
|
10148
|
+
console.log(
|
|
10149
|
+
chalk28.red(
|
|
10150
|
+
" Failed to check npm registry. Please check your internet connection."
|
|
10151
|
+
)
|
|
10152
|
+
);
|
|
10153
|
+
process.exit(1);
|
|
10154
|
+
}
|
|
10155
|
+
console.log(` Latest version: ${chalk28.green(latestVersion)}`);
|
|
10156
|
+
console.log("");
|
|
10157
|
+
if (compareSemver(CLI_VERSION, latestVersion) >= 0) {
|
|
10158
|
+
console.log(chalk28.green(" You are already on the latest version."));
|
|
10159
|
+
return;
|
|
10160
|
+
}
|
|
10161
|
+
console.log(
|
|
10162
|
+
chalk28.yellow(
|
|
10163
|
+
` Upgrading ${CLI_VERSION} \u2192 ${latestVersion}...`
|
|
10164
|
+
)
|
|
10165
|
+
);
|
|
10166
|
+
console.log("");
|
|
10167
|
+
try {
|
|
10168
|
+
execSync("npm install -g @test-bro/cli@latest", {
|
|
10169
|
+
stdio: "inherit"
|
|
10170
|
+
});
|
|
10171
|
+
writeCache(latestVersion);
|
|
10172
|
+
console.log("");
|
|
10173
|
+
console.log(
|
|
10174
|
+
chalk28.green(
|
|
10175
|
+
` Successfully upgraded to @test-bro/cli@${latestVersion}`
|
|
10176
|
+
)
|
|
10177
|
+
);
|
|
10178
|
+
} catch {
|
|
10179
|
+
console.log("");
|
|
10180
|
+
console.log(
|
|
10181
|
+
chalk28.red(
|
|
10182
|
+
" Upgrade failed. You may need to run with sudo or fix npm permissions."
|
|
10183
|
+
)
|
|
10184
|
+
);
|
|
10185
|
+
console.log(
|
|
10186
|
+
chalk28.dim(
|
|
10187
|
+
" Try: sudo npm install -g @test-bro/cli@latest"
|
|
10188
|
+
)
|
|
10189
|
+
);
|
|
10190
|
+
process.exit(1);
|
|
10191
|
+
}
|
|
10192
|
+
}
|
|
10193
|
+
|
|
9882
10194
|
// src/index.ts
|
|
9883
10195
|
var program = new Command();
|
|
9884
|
-
program.name("testbro").description("AI-powered browser testing from your terminal").version(
|
|
9885
|
-
program.command("login").description("Authenticate with TestBro using an API token").argument("[email]", "Your email address (deprecated - use --token instead)").option("-t, --token <token>", "API token for authentication").option("-u, --url <url>", "API URL (default:
|
|
10196
|
+
program.name("testbro").description("AI-powered browser testing from your terminal").version(CLI_VERSION);
|
|
10197
|
+
program.command("login").description("Authenticate with TestBro using an API token").argument("[email]", "Your email address (deprecated - use --token instead)").option("-t, --token <token>", "API token for authentication").option("-u, --url <url>", "API URL (default: https://testbro.dev)").action(async (email, options) => {
|
|
9886
10198
|
try {
|
|
9887
10199
|
await loginCommand(email, { token: options.token, url: options.url });
|
|
9888
10200
|
} catch (error) {
|
|
@@ -9985,7 +10297,7 @@ program.command("generate").description("Generate test cases from a file or desc
|
|
|
9985
10297
|
program.command("run").description("Run tests against a URL").option("-u, --url <url>", "Target URL to test (or use baseUrl from test-bro.config.json)").option("--file <path>", "Path to file (AC/PRD) to generate and run tests from").option("-d, --description <text>", "Feature description to test").option("-p, --project <id>", "Project ID to use test cases from (overrides active)").option("-t, --test-case <id>", "Run specific test case only").option("-m, --mode <mode>", "Execution mode: selector (default), ai, or hybrid", "selector").option("--headed", "Run browser in headed mode (visible)", false).option("--remote", "Run tests on remote staging environment", false).option("-f, --format <format>", "Output format: pretty or json", "pretty").option("-v, --verbose", "Show verbose output", false).option("--timeout <ms>", "Timeout per step in milliseconds", "30000").option("--learn-selectors", "Learn selectors from AI actions", false).option("--yes", "Auto-confirm selector learning", false).option("--no-dedup", "Skip deduplication check when using --file").option("--auto-merge", "Auto-apply smart merge without prompting when using --file").option("--record-video", "Record video of the test execution", false).option("--record-trace", "Capture Playwright trace for debugging", false).option("-e, --env <name>", "Use a named environment (fetches baseUrl from API)").option("--credential <name>", "Use a named credential from the vault").option("--no-sequential", "Run test cases independently (re-navigate for each)").action(async (options) => {
|
|
9986
10298
|
if (options.url && !validateUrl(options.url)) {
|
|
9987
10299
|
displayError("Invalid URL format", `Provided URL: ${options.url}`);
|
|
9988
|
-
console.log(
|
|
10300
|
+
console.log(chalk29.dim(' Example: testbro run --url https://example.com --description "..."'));
|
|
9989
10301
|
process.exit(1);
|
|
9990
10302
|
}
|
|
9991
10303
|
if (options.description) {
|
|
@@ -10231,6 +10543,17 @@ releasesCmd.command("unpublish").description("Unpublish a release note (revert t
|
|
|
10231
10543
|
process.exit(1);
|
|
10232
10544
|
}
|
|
10233
10545
|
});
|
|
10546
|
+
program.command("upgrade").description("Upgrade TestBro CLI to the latest version").action(async () => {
|
|
10547
|
+
try {
|
|
10548
|
+
await upgradeCommand();
|
|
10549
|
+
} catch (error) {
|
|
10550
|
+
if (error instanceof Error) {
|
|
10551
|
+
displayError(error.message);
|
|
10552
|
+
}
|
|
10553
|
+
printErrorHints(error);
|
|
10554
|
+
process.exit(1);
|
|
10555
|
+
}
|
|
10556
|
+
});
|
|
10234
10557
|
var configCmd = program.command("config").description("Show or update configuration");
|
|
10235
10558
|
function displayConfig() {
|
|
10236
10559
|
const { getConfigSummary: getConfigSummary2 } = (init_config(), __toCommonJS(config_exports));
|
|
@@ -10241,72 +10564,72 @@ function displayConfig() {
|
|
|
10241
10564
|
const userConfig = getConfigSummary2();
|
|
10242
10565
|
const projectConfigInfo = getProjectConfigSummary2();
|
|
10243
10566
|
console.log("");
|
|
10244
|
-
console.log(
|
|
10567
|
+
console.log(chalk29.bold("TestBro Configuration"));
|
|
10245
10568
|
console.log("");
|
|
10246
|
-
console.log(
|
|
10247
|
-
console.log(` ${
|
|
10248
|
-
console.log(` ${
|
|
10569
|
+
console.log(chalk29.underline("User Config") + chalk29.dim(` (~/.testbro/config.json)`));
|
|
10570
|
+
console.log(` ${chalk29.dim("Path:")} ${userConfig.configPath}`);
|
|
10571
|
+
console.log(` ${chalk29.dim("API URL:")} ${userConfig.apiUrl}`);
|
|
10249
10572
|
console.log(
|
|
10250
|
-
` ${
|
|
10573
|
+
` ${chalk29.dim("Authenticated:")} ${userConfig.authenticated ? chalk29.green("Yes") : chalk29.yellow("No")}`
|
|
10251
10574
|
);
|
|
10252
10575
|
if (userConfig.activeProjectId) {
|
|
10253
|
-
console.log(` ${
|
|
10576
|
+
console.log(` ${chalk29.dim("Active Project:")} ${userConfig.activeProjectId}`);
|
|
10254
10577
|
}
|
|
10255
10578
|
console.log("");
|
|
10256
|
-
console.log(
|
|
10579
|
+
console.log(chalk29.underline("Project Config") + chalk29.dim(` (test-bro.config.json)`));
|
|
10257
10580
|
if (projectConfigInfo.projectConfigPath) {
|
|
10258
|
-
console.log(` ${
|
|
10581
|
+
console.log(` ${chalk29.dim("Path:")} ${projectConfigInfo.projectConfigPath}`);
|
|
10259
10582
|
if (projectConfigInfo.error) {
|
|
10260
|
-
console.log(` ${
|
|
10583
|
+
console.log(` ${chalk29.red("Error:")} ${projectConfigInfo.error}`);
|
|
10261
10584
|
} else if (projectConfigInfo.projectConfig) {
|
|
10262
10585
|
const pc = projectConfigInfo.projectConfig;
|
|
10263
10586
|
if (pc.baseUrl) {
|
|
10264
|
-
console.log(` ${
|
|
10587
|
+
console.log(` ${chalk29.dim("Base URL:")} ${pc.baseUrl}`);
|
|
10265
10588
|
}
|
|
10266
10589
|
if (pc.environment) {
|
|
10267
|
-
console.log(` ${
|
|
10590
|
+
console.log(` ${chalk29.dim("Environment:")} ${pc.environment}`);
|
|
10268
10591
|
}
|
|
10269
10592
|
if (pc.mode) {
|
|
10270
|
-
console.log(` ${
|
|
10593
|
+
console.log(` ${chalk29.dim("Mode:")} ${pc.mode}`);
|
|
10271
10594
|
}
|
|
10272
10595
|
if (pc.timeout) {
|
|
10273
|
-
console.log(` ${
|
|
10596
|
+
console.log(` ${chalk29.dim("Timeout:")} ${pc.timeout}ms`);
|
|
10274
10597
|
}
|
|
10275
10598
|
if (pc.headed !== void 0) {
|
|
10276
|
-
console.log(` ${
|
|
10599
|
+
console.log(` ${chalk29.dim("Headed:")} ${pc.headed}`);
|
|
10277
10600
|
}
|
|
10278
10601
|
if (pc.projectId) {
|
|
10279
|
-
console.log(` ${
|
|
10602
|
+
console.log(` ${chalk29.dim("Project ID:")} ${pc.projectId}`);
|
|
10280
10603
|
}
|
|
10281
10604
|
const credNames = getCredentialNames2(pc);
|
|
10282
10605
|
if (credNames.length > 0) {
|
|
10283
|
-
console.log(` ${
|
|
10606
|
+
console.log(` ${chalk29.dim("Credentials:")} ${credNames.join(", ")}`);
|
|
10284
10607
|
}
|
|
10285
10608
|
if (pc.deduplication) {
|
|
10286
10609
|
const dedupEnabled = pc.deduplication.enabled !== false;
|
|
10287
10610
|
const autoMerge = pc.deduplication.autoMerge === true;
|
|
10288
|
-
console.log(` ${
|
|
10611
|
+
console.log(` ${chalk29.dim("Deduplication:")} ${dedupEnabled ? chalk29.green("enabled") : chalk29.yellow("disabled")}${autoMerge ? chalk29.dim(" (auto-merge)") : ""}`);
|
|
10289
10612
|
}
|
|
10290
10613
|
}
|
|
10291
10614
|
} else {
|
|
10292
|
-
console.log(` ${
|
|
10615
|
+
console.log(` ${chalk29.dim("Not found")}`);
|
|
10293
10616
|
}
|
|
10294
10617
|
console.log("");
|
|
10295
|
-
console.log(
|
|
10618
|
+
console.log(chalk29.underline("Authentication"));
|
|
10296
10619
|
if (projectConfigInfo.token) {
|
|
10297
10620
|
const maskedToken = projectConfigInfo.token.length > 8 ? `${projectConfigInfo.token.slice(0, 4)}...${projectConfigInfo.token.slice(-4)}` : "****";
|
|
10298
|
-
const sourceLabel = projectConfigInfo.tokenSource === "env" ?
|
|
10299
|
-
console.log(` ${
|
|
10621
|
+
const sourceLabel = projectConfigInfo.tokenSource === "env" ? chalk29.cyan("(from TEST_BRO_TOKEN env)") : chalk29.dim("(from user config)");
|
|
10622
|
+
console.log(` ${chalk29.dim("Token:")} ${maskedToken} ${sourceLabel}`);
|
|
10300
10623
|
} else {
|
|
10301
|
-
console.log(` ${
|
|
10302
|
-
console.log(
|
|
10303
|
-
console.log(
|
|
10624
|
+
console.log(` ${chalk29.yellow("No token configured")}`);
|
|
10625
|
+
console.log(chalk29.dim(" Set via: testbro init (or testbro login --token <token>)"));
|
|
10626
|
+
console.log(chalk29.dim(" Or set TEST_BRO_TOKEN environment variable"));
|
|
10304
10627
|
}
|
|
10305
10628
|
if (projectConfigInfo.openRouterApiKey) {
|
|
10306
10629
|
const maskedKey = projectConfigInfo.openRouterApiKey.length > 8 ? `${projectConfigInfo.openRouterApiKey.slice(0, 6)}...${projectConfigInfo.openRouterApiKey.slice(-4)}` : "****";
|
|
10307
|
-
console.log(` ${
|
|
10630
|
+
console.log(` ${chalk29.dim("OpenRouter API Key:")} ${maskedKey} ${chalk29.cyan("(from OPENROUTER_API_KEY env)")}`);
|
|
10308
10631
|
} else {
|
|
10309
|
-
console.log(` ${
|
|
10632
|
+
console.log(` ${chalk29.dim("OpenRouter API Key:")} ${chalk29.yellow("Not set")} ${chalk29.dim("(required for AI mode)")}`);
|
|
10310
10633
|
}
|
|
10311
10634
|
console.log("");
|
|
10312
10635
|
}
|
|
@@ -10320,11 +10643,11 @@ configCmd.command("init").description("Create a test-bro.config.json template in
|
|
|
10320
10643
|
try {
|
|
10321
10644
|
const { createConfigFile: createConfigFile2 } = (init_project_config(), __toCommonJS(project_config_exports));
|
|
10322
10645
|
const configPath = createConfigFile2();
|
|
10323
|
-
console.log(
|
|
10646
|
+
console.log(chalk29.green(`Created ${configPath}`));
|
|
10324
10647
|
console.log("");
|
|
10325
|
-
console.log(
|
|
10326
|
-
console.log(
|
|
10327
|
-
console.log(
|
|
10648
|
+
console.log(chalk29.dim("Edit this file to configure your project settings."));
|
|
10649
|
+
console.log(chalk29.dim("Note: Never store tokens in this file. Use .env or testbro login."));
|
|
10650
|
+
console.log(chalk29.dim("Tip: Use 'testbro init' for interactive setup with authentication."));
|
|
10328
10651
|
} catch (error) {
|
|
10329
10652
|
if (error instanceof Error) {
|
|
10330
10653
|
displayError(error.message);
|
|
@@ -10338,7 +10661,11 @@ configCmd.option("--api-url <url>", "Set the API URL in user config").hook("preA
|
|
|
10338
10661
|
if (options.apiUrl) {
|
|
10339
10662
|
const { setApiUrl: setApiUrl2 } = (init_config(), __toCommonJS(config_exports));
|
|
10340
10663
|
setApiUrl2(options.apiUrl);
|
|
10341
|
-
console.log(
|
|
10664
|
+
console.log(chalk29.green(`API URL updated to: ${options.apiUrl}`));
|
|
10342
10665
|
}
|
|
10343
10666
|
});
|
|
10667
|
+
var showVersionNotice = startVersionCheck(CLI_VERSION);
|
|
10668
|
+
program.hook("postAction", async () => {
|
|
10669
|
+
await showVersionNotice();
|
|
10670
|
+
});
|
|
10344
10671
|
program.parse();
|
package/package.json
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.2",
|
|
7
7
|
"description": "TestBro CLI - AI-powered browser testing from your terminal",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|
|
10
|
-
"testbro": "
|
|
10
|
+
"testbro": "dist/index.js"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"dist"
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"repository": {
|
|
28
28
|
"type": "git",
|
|
29
|
-
"url": "https://github.com/chernobelenkiy/test-bro.git",
|
|
29
|
+
"url": "git+https://github.com/chernobelenkiy/test-bro.git",
|
|
30
30
|
"directory": "apps/cli"
|
|
31
31
|
},
|
|
32
32
|
"license": "MIT",
|