@test-bro/cli 0.1.1 → 0.1.3
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 +1580 -344
- package/package.json +1 -1
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;
|
|
@@ -40,6 +39,7 @@ __export(config_exports, {
|
|
|
40
39
|
getConfigSummary: () => getConfigSummary,
|
|
41
40
|
getToken: () => getToken,
|
|
42
41
|
isAuthenticated: () => isAuthenticated,
|
|
42
|
+
overrideApiUrl: () => overrideApiUrl,
|
|
43
43
|
readConfig: () => readConfig,
|
|
44
44
|
removeActiveProject: () => removeActiveProject,
|
|
45
45
|
removeApiKey: () => removeApiKey,
|
|
@@ -154,11 +154,14 @@ function removeActiveProject() {
|
|
|
154
154
|
writeConfigRaw(config);
|
|
155
155
|
}
|
|
156
156
|
function getApiUrl() {
|
|
157
|
-
return readConfig().apiUrl;
|
|
157
|
+
return apiUrlOverride ?? readConfig().apiUrl;
|
|
158
158
|
}
|
|
159
159
|
function setApiUrl(apiUrl) {
|
|
160
160
|
writeConfig({ apiUrl });
|
|
161
161
|
}
|
|
162
|
+
function overrideApiUrl(apiUrl) {
|
|
163
|
+
apiUrlOverride = apiUrl;
|
|
164
|
+
}
|
|
162
165
|
function isAuthenticated() {
|
|
163
166
|
const token = getToken();
|
|
164
167
|
return token !== void 0 && token.length > 0;
|
|
@@ -176,12 +179,12 @@ function getConfigSummary() {
|
|
|
176
179
|
configPath: getConfigPath()
|
|
177
180
|
};
|
|
178
181
|
}
|
|
179
|
-
var DEFAULT_CONFIG;
|
|
182
|
+
var DEFAULT_CONFIG, apiUrlOverride;
|
|
180
183
|
var init_config = __esm({
|
|
181
184
|
"src/config.ts"() {
|
|
182
185
|
"use strict";
|
|
183
186
|
DEFAULT_CONFIG = {
|
|
184
|
-
apiUrl: "https://
|
|
187
|
+
apiUrl: "https://testbro.dev"
|
|
185
188
|
};
|
|
186
189
|
}
|
|
187
190
|
});
|
|
@@ -198,6 +201,7 @@ __export(project_config_exports, {
|
|
|
198
201
|
getProjectToken: () => getProjectToken,
|
|
199
202
|
loadProjectConfig: () => loadProjectConfig,
|
|
200
203
|
mergeWithCliOptions: () => mergeWithCliOptions,
|
|
204
|
+
resolveActiveEnvironment: () => resolveActiveEnvironment,
|
|
201
205
|
substituteCredentials: () => substituteCredentials
|
|
202
206
|
});
|
|
203
207
|
import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -258,6 +262,13 @@ function getCredentialNames(config) {
|
|
|
258
262
|
}
|
|
259
263
|
return Object.keys(config.credentials);
|
|
260
264
|
}
|
|
265
|
+
function resolveActiveEnvironment(config, envName) {
|
|
266
|
+
if (!config?.environments) return null;
|
|
267
|
+
const name = envName || config.environment || "local";
|
|
268
|
+
const entry = config.environments[name];
|
|
269
|
+
if (!entry) return null;
|
|
270
|
+
return { name, entry };
|
|
271
|
+
}
|
|
261
272
|
function substituteCredentials(value, config) {
|
|
262
273
|
if (!config?.credentials || !value.includes("{{credentials.")) {
|
|
263
274
|
return value;
|
|
@@ -339,7 +350,7 @@ function mergeWithCliOptions(projectConfig, cliOptions) {
|
|
|
339
350
|
return merged;
|
|
340
351
|
}
|
|
341
352
|
function getProjectConfigSummary() {
|
|
342
|
-
const { config, path:
|
|
353
|
+
const { config, path: path10, error } = loadProjectConfig();
|
|
343
354
|
let tokenSource = null;
|
|
344
355
|
let token = null;
|
|
345
356
|
if (process.env.TEST_BRO_TOKEN) {
|
|
@@ -357,7 +368,7 @@ function getProjectConfigSummary() {
|
|
|
357
368
|
}
|
|
358
369
|
}
|
|
359
370
|
return {
|
|
360
|
-
projectConfigPath:
|
|
371
|
+
projectConfigPath: path10,
|
|
361
372
|
projectConfig: config,
|
|
362
373
|
error,
|
|
363
374
|
token,
|
|
@@ -365,7 +376,7 @@ function getProjectConfigSummary() {
|
|
|
365
376
|
openRouterApiKey: getOpenRouterApiKey()
|
|
366
377
|
};
|
|
367
378
|
}
|
|
368
|
-
var credentialSchema,
|
|
379
|
+
var credentialSchema, deduplicationConfigSchema, authStepSchema, authConfigSchema, environmentEntrySchema, projectConfigSchema, CONFIG_FILE_NAMES, CONFIG_TEMPLATE;
|
|
369
380
|
var init_project_config = __esm({
|
|
370
381
|
"src/project-config.ts"() {
|
|
371
382
|
"use strict";
|
|
@@ -373,11 +384,6 @@ var init_project_config = __esm({
|
|
|
373
384
|
email: z.string().email(),
|
|
374
385
|
password: z.string().min(1)
|
|
375
386
|
});
|
|
376
|
-
localEnvironmentSchema = z.object({
|
|
377
|
-
name: z.string(),
|
|
378
|
-
baseUrl: z.string().url(),
|
|
379
|
-
variables: z.record(z.string()).optional()
|
|
380
|
-
});
|
|
381
387
|
deduplicationConfigSchema = z.object({
|
|
382
388
|
enabled: z.boolean().optional(),
|
|
383
389
|
autoMerge: z.boolean().optional()
|
|
@@ -393,7 +399,16 @@ var init_project_config = __esm({
|
|
|
393
399
|
steps: z.array(authStepSchema),
|
|
394
400
|
credentials: z.record(z.string(), z.string()).optional(),
|
|
395
401
|
storageStatePath: z.string().optional(),
|
|
396
|
-
maxAge: z.number().optional()
|
|
402
|
+
maxAge: z.number().optional(),
|
|
403
|
+
waitForUrl: z.string().optional()
|
|
404
|
+
});
|
|
405
|
+
environmentEntrySchema = z.object({
|
|
406
|
+
baseUrl: z.string().url(),
|
|
407
|
+
apiUrl: z.string().url().optional(),
|
|
408
|
+
credentials: z.record(z.string(), credentialSchema).optional(),
|
|
409
|
+
auth: authConfigSchema.optional(),
|
|
410
|
+
seed: z.string().optional(),
|
|
411
|
+
variables: z.record(z.string()).optional()
|
|
397
412
|
});
|
|
398
413
|
projectConfigSchema = z.object({
|
|
399
414
|
$schema: z.string().optional(),
|
|
@@ -404,7 +419,7 @@ var init_project_config = __esm({
|
|
|
404
419
|
headed: z.boolean().optional(),
|
|
405
420
|
projectId: z.string().optional(),
|
|
406
421
|
credentials: z.record(z.string(), credentialSchema).optional(),
|
|
407
|
-
environments: z.
|
|
422
|
+
environments: z.record(z.string(), environmentEntrySchema).optional(),
|
|
408
423
|
learnSelectors: z.boolean().optional(),
|
|
409
424
|
verbose: z.boolean().optional(),
|
|
410
425
|
deduplication: deduplicationConfigSchema.optional(),
|
|
@@ -428,6 +443,17 @@ var init_project_config = __esm({
|
|
|
428
443
|
password: "password123"
|
|
429
444
|
}
|
|
430
445
|
},
|
|
446
|
+
environments: {
|
|
447
|
+
local: {
|
|
448
|
+
baseUrl: "http://localhost:3000",
|
|
449
|
+
credentials: {
|
|
450
|
+
default: {
|
|
451
|
+
email: "test@example.com",
|
|
452
|
+
password: "password123"
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
},
|
|
431
457
|
deduplication: {
|
|
432
458
|
enabled: true,
|
|
433
459
|
autoMerge: false
|
|
@@ -439,7 +465,7 @@ var init_project_config = __esm({
|
|
|
439
465
|
// src/index.ts
|
|
440
466
|
import "dotenv/config";
|
|
441
467
|
import { Command } from "commander";
|
|
442
|
-
import
|
|
468
|
+
import chalk29 from "chalk";
|
|
443
469
|
|
|
444
470
|
// src/commands/login.ts
|
|
445
471
|
import chalk3 from "chalk";
|
|
@@ -616,6 +642,7 @@ async function apiRequestCore(endpoint, options = {}, authToken, requireAuth = t
|
|
|
616
642
|
};
|
|
617
643
|
if (token) {
|
|
618
644
|
headers.Authorization = `Bearer ${token}`;
|
|
645
|
+
headers["x-api-key"] = token;
|
|
619
646
|
}
|
|
620
647
|
const response = await fetch(url, {
|
|
621
648
|
...options,
|
|
@@ -728,15 +755,6 @@ async function compareTestCases(projectId, testCases) {
|
|
|
728
755
|
}
|
|
729
756
|
);
|
|
730
757
|
}
|
|
731
|
-
async function saveLearnedSelectors(projectId, testCaseId, learnedSelectors) {
|
|
732
|
-
return apiRequest(
|
|
733
|
-
`/api/projects/${projectId}/test-cases/${testCaseId}/learn-selectors`,
|
|
734
|
-
{
|
|
735
|
-
method: "POST",
|
|
736
|
-
body: JSON.stringify({ learnedSelectors })
|
|
737
|
-
}
|
|
738
|
-
);
|
|
739
|
-
}
|
|
740
758
|
async function applyCorrections(projectId, testCaseId, corrections) {
|
|
741
759
|
return apiRequest(
|
|
742
760
|
`/api/projects/${projectId}/test-cases/${testCaseId}/apply-corrections`,
|
|
@@ -816,40 +834,50 @@ async function uploadArtifacts(runId, options) {
|
|
|
816
834
|
}
|
|
817
835
|
const token = getToken();
|
|
818
836
|
if (!token) {
|
|
819
|
-
|
|
837
|
+
throw new Error("No API token found for artifact upload");
|
|
820
838
|
}
|
|
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
|
-
|
|
839
|
+
const apiUrl = getApiUrl();
|
|
840
|
+
const url = `${apiUrl}/api/runs/${runId}/artifacts`;
|
|
841
|
+
const formData = new FormData();
|
|
842
|
+
if (videoPath) {
|
|
843
|
+
const videoBuffer = fs2.readFileSync(videoPath);
|
|
844
|
+
const videoType = videoPath.endsWith(".mp4") ? "video/mp4" : "video/webm";
|
|
845
|
+
const videoBlob = new Blob([videoBuffer], { type: videoType });
|
|
846
|
+
const videoFileName = videoPath.split("/").pop() || "video.webm";
|
|
847
|
+
formData.append("video", videoBlob, videoFileName);
|
|
848
|
+
}
|
|
849
|
+
if (tracePath) {
|
|
850
|
+
const traceBuffer = fs2.readFileSync(tracePath);
|
|
851
|
+
const traceBlob = new Blob([traceBuffer], { type: "application/zip" });
|
|
852
|
+
const traceFileName = tracePath.split("/").pop() || "trace.zip";
|
|
853
|
+
formData.append("trace", traceBlob, traceFileName);
|
|
854
|
+
}
|
|
855
|
+
const response = await fetch(url, {
|
|
856
|
+
method: "POST",
|
|
857
|
+
headers: {
|
|
858
|
+
Authorization: `Bearer ${token}`
|
|
859
|
+
},
|
|
860
|
+
body: formData
|
|
861
|
+
});
|
|
862
|
+
if (!response.ok) {
|
|
863
|
+
let details = "";
|
|
864
|
+
try {
|
|
865
|
+
const contentType = response.headers.get("content-type") || "";
|
|
866
|
+
if (contentType.includes("application/json")) {
|
|
867
|
+
const errData = await response.json();
|
|
868
|
+
details = errData.error || JSON.stringify(errData.details || errData.data || "");
|
|
869
|
+
} else {
|
|
870
|
+
details = await response.text();
|
|
871
|
+
}
|
|
872
|
+
} catch {
|
|
873
|
+
details = "";
|
|
847
874
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
return {};
|
|
875
|
+
throw new Error(
|
|
876
|
+
`Artifact upload failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${details ? `: ${details}` : ""}`
|
|
877
|
+
);
|
|
852
878
|
}
|
|
879
|
+
const data = await response.json();
|
|
880
|
+
return data.data || {};
|
|
853
881
|
}
|
|
854
882
|
async function listFeatures(projectId) {
|
|
855
883
|
return apiRequest(`/api/projects/${projectId}/features`);
|
|
@@ -877,63 +905,102 @@ async function assignTestCaseToFeature(projectId, featureSlug, testCaseId) {
|
|
|
877
905
|
}
|
|
878
906
|
);
|
|
879
907
|
}
|
|
880
|
-
async function
|
|
908
|
+
async function listSelectorPages(projectId, options) {
|
|
881
909
|
const params = new URLSearchParams();
|
|
882
|
-
if (options?.
|
|
883
|
-
|
|
884
|
-
const feature = features.find((f) => f.slug === options.featureSlug);
|
|
885
|
-
if (feature) {
|
|
886
|
-
params.set("featureId", feature.id);
|
|
887
|
-
} else {
|
|
888
|
-
return { selectors: [], total: 0, page: 1, limit: 50 };
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
if (options?.status) {
|
|
892
|
-
params.set("status", options.status);
|
|
893
|
-
}
|
|
910
|
+
if (options?.tag) params.set("tag", options.tag);
|
|
911
|
+
if (options?.search) params.set("search", options.search);
|
|
894
912
|
const query = params.toString();
|
|
895
913
|
return apiRequest(
|
|
896
|
-
`/api/projects/${projectId}/
|
|
914
|
+
`/api/projects/${projectId}/selector-pages${query ? `?${query}` : ""}`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
async function resolveSelector(projectId, data) {
|
|
918
|
+
return apiRequest(
|
|
919
|
+
`/api/projects/${projectId}/selector-resolve`,
|
|
920
|
+
{
|
|
921
|
+
method: "POST",
|
|
922
|
+
body: JSON.stringify(data)
|
|
923
|
+
}
|
|
897
924
|
);
|
|
898
925
|
}
|
|
899
|
-
async function
|
|
926
|
+
async function learnSelectors(projectId, pageUrl, learnedElements) {
|
|
900
927
|
return apiRequest(
|
|
901
|
-
`/api/projects/${projectId}/
|
|
928
|
+
`/api/projects/${projectId}/selector-learn`,
|
|
902
929
|
{
|
|
903
930
|
method: "POST",
|
|
904
|
-
body: JSON.stringify(
|
|
931
|
+
body: JSON.stringify({ pageUrl, learnedElements })
|
|
905
932
|
}
|
|
906
933
|
);
|
|
907
934
|
}
|
|
908
|
-
async function
|
|
935
|
+
async function listSelectorElements(projectId, filters) {
|
|
909
936
|
const params = new URLSearchParams();
|
|
910
|
-
params.set("
|
|
911
|
-
if (
|
|
912
|
-
if (
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
937
|
+
if (filters?.pageId) params.set("pageId", filters.pageId);
|
|
938
|
+
if (filters?.status) params.set("status", filters.status);
|
|
939
|
+
if (filters?.tag) params.set("tag", filters.tag);
|
|
940
|
+
if (filters?.search) params.set("search", filters.search);
|
|
941
|
+
const query = params.toString();
|
|
942
|
+
return apiRequest(
|
|
943
|
+
`/api/projects/${projectId}/selector-elements${query ? `?${query}` : ""}`
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
async function getSelectorElementHistory(projectId, elementId, limit, offset) {
|
|
947
|
+
const params = new URLSearchParams();
|
|
948
|
+
if (limit) params.set("limit", String(limit));
|
|
949
|
+
if (offset) params.set("offset", String(offset));
|
|
950
|
+
const query = params.toString();
|
|
951
|
+
return apiRequest(
|
|
952
|
+
`/api/projects/${projectId}/selector-elements/${elementId}/history${query ? `?${query}` : ""}`
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
async function rollbackSelectorElement(projectId, elementId, historyId) {
|
|
956
|
+
return apiRequest(
|
|
957
|
+
`/api/projects/${projectId}/selector-elements/${elementId}/rollback`,
|
|
958
|
+
{
|
|
959
|
+
method: "POST",
|
|
960
|
+
body: JSON.stringify({ historyId })
|
|
961
|
+
}
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
async function exportSelectorLibrary(projectId) {
|
|
965
|
+
return apiRequest(
|
|
966
|
+
`/api/projects/${projectId}/selector-library/export`
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
async function importSelectorLibrary(projectId, data) {
|
|
970
|
+
return apiRequest(
|
|
971
|
+
`/api/projects/${projectId}/selector-library/import`,
|
|
972
|
+
{
|
|
973
|
+
method: "POST",
|
|
974
|
+
body: JSON.stringify(data)
|
|
975
|
+
}
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
async function createSelectorPage(projectId, data) {
|
|
979
|
+
return apiRequest(
|
|
980
|
+
`/api/projects/${projectId}/selector-pages`,
|
|
981
|
+
{
|
|
982
|
+
method: "POST",
|
|
983
|
+
body: JSON.stringify(data)
|
|
984
|
+
}
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
async function createSelectorSection(projectId, pageId, data) {
|
|
988
|
+
return apiRequest(
|
|
989
|
+
`/api/projects/${projectId}/selector-pages/${pageId}/sections`,
|
|
990
|
+
{
|
|
991
|
+
method: "POST",
|
|
992
|
+
body: JSON.stringify(data)
|
|
993
|
+
}
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
async function createSelectorElement(projectId, data) {
|
|
997
|
+
return apiRequest(
|
|
998
|
+
`/api/projects/${projectId}/selector-elements`,
|
|
999
|
+
{
|
|
1000
|
+
method: "POST",
|
|
1001
|
+
body: JSON.stringify(data)
|
|
930
1002
|
}
|
|
931
|
-
return await response.text();
|
|
932
|
-
}
|
|
933
|
-
const data = await apiRequest(
|
|
934
|
-
`/api/projects/${projectId}/selectors/export?${params.toString()}`
|
|
935
1003
|
);
|
|
936
|
-
return JSON.stringify(data.selectors, null, 2);
|
|
937
1004
|
}
|
|
938
1005
|
async function createDemoRun(projectId, data) {
|
|
939
1006
|
return apiRequest(
|
|
@@ -1034,10 +1101,10 @@ function extractValidationHints(details) {
|
|
|
1034
1101
|
for (const issue of obj.issues) {
|
|
1035
1102
|
if (typeof issue === "object" && issue !== null) {
|
|
1036
1103
|
const i = issue;
|
|
1037
|
-
const
|
|
1104
|
+
const path10 = Array.isArray(i.path) ? i.path.join(".") : String(i.path ?? "");
|
|
1038
1105
|
const message = typeof i.message === "string" ? i.message : "";
|
|
1039
|
-
if (
|
|
1040
|
-
hints.push(`${
|
|
1106
|
+
if (path10 && message) {
|
|
1107
|
+
hints.push(`${path10}: ${message}`);
|
|
1041
1108
|
} else if (message) {
|
|
1042
1109
|
hints.push(message);
|
|
1043
1110
|
}
|
|
@@ -1419,7 +1486,7 @@ async function quickstartCommand(options = {}) {
|
|
|
1419
1486
|
console.log("");
|
|
1420
1487
|
console.log(chalk5.yellow(" Please authenticate first:"));
|
|
1421
1488
|
console.log("");
|
|
1422
|
-
console.log(chalk5.dim(" 1. Go to ") + chalk5.cyan("
|
|
1489
|
+
console.log(chalk5.dim(" 1. Go to ") + chalk5.cyan("https://testbro.dev/dashboard/settings"));
|
|
1423
1490
|
console.log(chalk5.dim(" 2. Create an API token"));
|
|
1424
1491
|
console.log(chalk5.dim(" 3. Run: ") + chalk5.cyan("testbro init") + chalk5.dim(" (or testbro login --token <your-token>)"));
|
|
1425
1492
|
console.log("");
|
|
@@ -1434,7 +1501,7 @@ async function quickstartCommand(options = {}) {
|
|
|
1434
1501
|
spinner.fail("Token is invalid or expired");
|
|
1435
1502
|
console.log("");
|
|
1436
1503
|
console.log(chalk5.yellow(" Please re-authenticate:"));
|
|
1437
|
-
console.log(chalk5.dim(" 1. Go to ") + chalk5.cyan("
|
|
1504
|
+
console.log(chalk5.dim(" 1. Go to ") + chalk5.cyan("https://testbro.dev/dashboard/settings"));
|
|
1438
1505
|
console.log(chalk5.dim(" 2. Create a new API token"));
|
|
1439
1506
|
console.log(chalk5.dim(" 3. Run: ") + chalk5.cyan("testbro init") + chalk5.dim(" (or testbro login --token <your-token>)"));
|
|
1440
1507
|
console.log("");
|
|
@@ -1717,29 +1784,42 @@ async function resolveEnvironment(name, projectId, options = {}) {
|
|
|
1717
1784
|
if (preferCloud) {
|
|
1718
1785
|
return await resolveEnvironmentFromCloud(name, projectId) ?? resolveEnvironmentFromLocal(name);
|
|
1719
1786
|
}
|
|
1720
|
-
return resolveEnvironmentFromLocal(name) ?? (
|
|
1787
|
+
return resolveEnvironmentFromLocal(name) ?? (isAuthenticated() ? resolveEnvironmentFromCloud(name, projectId) : null);
|
|
1721
1788
|
}
|
|
1722
1789
|
async function resolveCredential(name, projectId, options = {}) {
|
|
1723
1790
|
const { preferCloud = false } = options;
|
|
1724
1791
|
if (preferCloud) {
|
|
1725
1792
|
return await resolveCredentialFromCloud(name, projectId) ?? resolveCredentialFromLocal(name);
|
|
1726
1793
|
}
|
|
1727
|
-
return resolveCredentialFromLocal(name) ?? (
|
|
1794
|
+
return resolveCredentialFromLocal(name) ?? (isAuthenticated() ? resolveCredentialFromCloud(name, projectId) : null);
|
|
1728
1795
|
}
|
|
1729
1796
|
function resolveEnvironmentFromLocal(name) {
|
|
1730
1797
|
const { config } = loadProjectConfig();
|
|
1731
1798
|
if (!config?.environments) return null;
|
|
1732
|
-
const
|
|
1733
|
-
(
|
|
1799
|
+
const key = Object.keys(config.environments).find(
|
|
1800
|
+
(k) => k.toLowerCase() === name.toLowerCase()
|
|
1734
1801
|
);
|
|
1735
|
-
if (!
|
|
1802
|
+
if (!key) return null;
|
|
1803
|
+
const entry = config.environments[key];
|
|
1736
1804
|
return {
|
|
1737
|
-
name:
|
|
1738
|
-
baseUrl:
|
|
1739
|
-
variables:
|
|
1805
|
+
name: key,
|
|
1806
|
+
baseUrl: entry.baseUrl,
|
|
1807
|
+
variables: entry.variables ?? {},
|
|
1740
1808
|
source: "local"
|
|
1741
1809
|
};
|
|
1742
1810
|
}
|
|
1811
|
+
function resolveEnvironmentConfig(envName, projectConfig) {
|
|
1812
|
+
if (!projectConfig?.environments) return null;
|
|
1813
|
+
const resolved = resolveActiveEnvironment(projectConfig, envName);
|
|
1814
|
+
if (!resolved) return null;
|
|
1815
|
+
return {
|
|
1816
|
+
baseUrl: resolved.entry.baseUrl,
|
|
1817
|
+
apiUrl: resolved.entry.apiUrl,
|
|
1818
|
+
credentials: resolved.entry.credentials,
|
|
1819
|
+
auth: resolved.entry.auth,
|
|
1820
|
+
envName: resolved.name
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1743
1823
|
async function resolveEnvironmentFromCloud(name, projectId) {
|
|
1744
1824
|
try {
|
|
1745
1825
|
const environments = await getProjectEnvironments(projectId);
|
|
@@ -1798,10 +1878,6 @@ async function resolveCredentialFromCloud(name, projectId) {
|
|
|
1798
1878
|
throw error;
|
|
1799
1879
|
}
|
|
1800
1880
|
}
|
|
1801
|
-
function isAuthenticated2() {
|
|
1802
|
-
const token = getToken();
|
|
1803
|
-
return token !== void 0 && token.length > 0;
|
|
1804
|
-
}
|
|
1805
1881
|
|
|
1806
1882
|
// src/commands/auth/auth-executor.ts
|
|
1807
1883
|
import * as fs3 from "fs";
|
|
@@ -1875,7 +1951,7 @@ var AuthExecutor = class _AuthExecutor {
|
|
|
1875
1951
|
);
|
|
1876
1952
|
await _AuthExecutor.executeStep(page, resolvedStep);
|
|
1877
1953
|
}
|
|
1878
|
-
await
|
|
1954
|
+
await _AuthExecutor.waitForAuthComplete(page, authConfig, loginUrl);
|
|
1879
1955
|
await context.storageState({ path: statePath });
|
|
1880
1956
|
return statePath;
|
|
1881
1957
|
} finally {
|
|
@@ -2034,6 +2110,42 @@ var AuthExecutor = class _AuthExecutor {
|
|
|
2034
2110
|
}
|
|
2035
2111
|
}
|
|
2036
2112
|
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Wait for the authentication flow to fully complete after all steps.
|
|
2115
|
+
*
|
|
2116
|
+
* Handles redirect-based auth flows (e.g., NextAuth v5 server actions)
|
|
2117
|
+
* where the session cookie is set during a redirect chain, not on the
|
|
2118
|
+
* initial response. Uses three strategies in order:
|
|
2119
|
+
*
|
|
2120
|
+
* 1. If `waitForUrl` is configured, wait for that specific URL pattern
|
|
2121
|
+
* 2. Otherwise, detect URL change from the login page (redirect completed)
|
|
2122
|
+
* 3. Wait for network idle to ensure all Set-Cookie headers are processed
|
|
2123
|
+
*/
|
|
2124
|
+
static async waitForAuthComplete(page, authConfig, loginUrl) {
|
|
2125
|
+
const waitTimeout = 15e3;
|
|
2126
|
+
if (authConfig.waitForUrl) {
|
|
2127
|
+
try {
|
|
2128
|
+
await page.waitForURL(authConfig.waitForUrl, {
|
|
2129
|
+
timeout: waitTimeout,
|
|
2130
|
+
waitUntil: "load"
|
|
2131
|
+
});
|
|
2132
|
+
} catch {
|
|
2133
|
+
}
|
|
2134
|
+
} else {
|
|
2135
|
+
try {
|
|
2136
|
+
await page.waitForURL(
|
|
2137
|
+
(url) => url.toString() !== loginUrl,
|
|
2138
|
+
{ timeout: waitTimeout, waitUntil: "load" }
|
|
2139
|
+
);
|
|
2140
|
+
} catch {
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
try {
|
|
2144
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 });
|
|
2145
|
+
} catch {
|
|
2146
|
+
}
|
|
2147
|
+
await page.waitForTimeout(1e3);
|
|
2148
|
+
}
|
|
2037
2149
|
/**
|
|
2038
2150
|
* Delete the cached storage state file.
|
|
2039
2151
|
* @param statePath - Path to the storage state file
|
|
@@ -2733,8 +2845,8 @@ function stripLoginSteps(steps) {
|
|
|
2733
2845
|
}
|
|
2734
2846
|
return loginEndIndex > 0 ? steps.slice(loginEndIndex) : steps;
|
|
2735
2847
|
}
|
|
2736
|
-
function convertToTestPlan(projectTestCases, runId, projectConfig) {
|
|
2737
|
-
const
|
|
2848
|
+
function convertToTestPlan(projectTestCases, runId, projectConfig, authEnabled) {
|
|
2849
|
+
const shouldStripLogin = authEnabled ?? !!projectConfig?.auth;
|
|
2738
2850
|
const testCases = projectTestCases.map((ptc) => {
|
|
2739
2851
|
const steps = ptc.steps.map((step, stepIndex) => {
|
|
2740
2852
|
let target;
|
|
@@ -2772,7 +2884,7 @@ function convertToTestPlan(projectTestCases, runId, projectConfig) {
|
|
|
2772
2884
|
assertion
|
|
2773
2885
|
};
|
|
2774
2886
|
});
|
|
2775
|
-
const finalSteps =
|
|
2887
|
+
const finalSteps = shouldStripLogin ? stripLoginSteps(steps) : steps;
|
|
2776
2888
|
return {
|
|
2777
2889
|
id: ptc.id,
|
|
2778
2890
|
name: ptc.name,
|
|
@@ -2799,8 +2911,11 @@ async function buildTestPlan(options) {
|
|
|
2799
2911
|
generatedFromFile,
|
|
2800
2912
|
fileGeneratedTestCases,
|
|
2801
2913
|
projectConfig,
|
|
2802
|
-
isPretty
|
|
2914
|
+
isPretty,
|
|
2915
|
+
environment,
|
|
2916
|
+
authEnabled
|
|
2803
2917
|
} = options;
|
|
2918
|
+
const envName = environment || (remote ? "staging" : "local");
|
|
2804
2919
|
const useProjectTestCases = !description && !generatedFromFile && projectId;
|
|
2805
2920
|
if (generatedFromFile && fileGeneratedTestCases) {
|
|
2806
2921
|
return buildFromGeneratedTestCases(
|
|
@@ -2808,9 +2923,10 @@ async function buildTestPlan(options) {
|
|
|
2808
2923
|
file,
|
|
2809
2924
|
projectId,
|
|
2810
2925
|
url,
|
|
2811
|
-
|
|
2926
|
+
envName,
|
|
2812
2927
|
projectConfig,
|
|
2813
|
-
isPretty
|
|
2928
|
+
isPretty,
|
|
2929
|
+
authEnabled
|
|
2814
2930
|
);
|
|
2815
2931
|
}
|
|
2816
2932
|
if (useProjectTestCases) {
|
|
@@ -2818,34 +2934,35 @@ async function buildTestPlan(options) {
|
|
|
2818
2934
|
projectId,
|
|
2819
2935
|
testCaseId,
|
|
2820
2936
|
url,
|
|
2821
|
-
|
|
2937
|
+
envName,
|
|
2822
2938
|
projectConfig,
|
|
2823
|
-
isPretty
|
|
2939
|
+
isPretty,
|
|
2940
|
+
authEnabled
|
|
2824
2941
|
);
|
|
2825
2942
|
}
|
|
2826
|
-
return buildFromDescription(description, url,
|
|
2943
|
+
return buildFromDescription(description, url, envName, projectId, isPretty);
|
|
2827
2944
|
}
|
|
2828
|
-
async function buildFromGeneratedTestCases(testCases, file, projectId, url,
|
|
2945
|
+
async function buildFromGeneratedTestCases(testCases, file, projectId, url, envName, projectConfig, isPretty, authEnabled) {
|
|
2829
2946
|
const { project: projectInfo } = await getProject(projectId);
|
|
2830
2947
|
const createSpinnerInstance = isPretty ? createSpinner("Creating test run...").start() : null;
|
|
2831
2948
|
const featureDesc = `Generated from: ${path4.basename(file)}`;
|
|
2832
2949
|
const createResponse = await createRun(
|
|
2833
2950
|
featureDesc,
|
|
2834
2951
|
url,
|
|
2835
|
-
|
|
2952
|
+
envName,
|
|
2836
2953
|
projectId
|
|
2837
2954
|
);
|
|
2838
2955
|
const runId = createResponse.run.id;
|
|
2839
2956
|
createSpinnerInstance?.succeed(
|
|
2840
2957
|
`Test run created ${chalk9.dim(`(ID: ${runId.slice(0, 8)}...)`)}`
|
|
2841
2958
|
);
|
|
2842
|
-
const testPlan = convertToTestPlan(testCases, runId, projectConfig);
|
|
2959
|
+
const testPlan = convertToTestPlan(testCases, runId, projectConfig, authEnabled);
|
|
2843
2960
|
if (isPretty) {
|
|
2844
2961
|
log.info(`Running ${testCases.length} generated test case${testCases.length === 1 ? "" : "s"}...`);
|
|
2845
2962
|
}
|
|
2846
2963
|
return { runId, testPlan };
|
|
2847
2964
|
}
|
|
2848
|
-
async function buildFromProjectTestCases(projectId, testCaseId, url,
|
|
2965
|
+
async function buildFromProjectTestCases(projectId, testCaseId, url, envName, projectConfig, isPretty, authEnabled) {
|
|
2849
2966
|
const projectSpinner = isPretty ? createSpinner("Fetching project test cases...").start() : null;
|
|
2850
2967
|
try {
|
|
2851
2968
|
const { project: projectInfo } = await getProject(projectId);
|
|
@@ -2871,14 +2988,14 @@ async function buildFromProjectTestCases(projectId, testCaseId, url, remote, pro
|
|
|
2871
2988
|
const createResponse = await createRun(
|
|
2872
2989
|
featureDesc,
|
|
2873
2990
|
url,
|
|
2874
|
-
|
|
2991
|
+
envName,
|
|
2875
2992
|
projectId
|
|
2876
2993
|
);
|
|
2877
2994
|
const runId = createResponse.run.id;
|
|
2878
2995
|
createSpinnerInstance?.succeed(
|
|
2879
2996
|
`Test run created ${chalk9.dim(`(ID: ${runId.slice(0, 8)}...)`)}`
|
|
2880
2997
|
);
|
|
2881
|
-
const testPlan = convertToTestPlan(projectTestCases, runId, projectConfig);
|
|
2998
|
+
const testPlan = convertToTestPlan(projectTestCases, runId, projectConfig, authEnabled);
|
|
2882
2999
|
return { runId, testPlan };
|
|
2883
3000
|
} catch (error) {
|
|
2884
3001
|
projectSpinner?.fail("Failed to fetch project");
|
|
@@ -2895,12 +3012,12 @@ async function buildFromProjectTestCases(projectId, testCaseId, url, remote, pro
|
|
|
2895
3012
|
process.exit(1);
|
|
2896
3013
|
}
|
|
2897
3014
|
}
|
|
2898
|
-
async function buildFromDescription(description, url,
|
|
3015
|
+
async function buildFromDescription(description, url, envName, projectId, isPretty) {
|
|
2899
3016
|
const createSpinnerInstance = isPretty ? createSpinner("Creating test run...").start() : null;
|
|
2900
3017
|
const createResponse = await createRun(
|
|
2901
3018
|
description,
|
|
2902
3019
|
url,
|
|
2903
|
-
|
|
3020
|
+
envName,
|
|
2904
3021
|
projectId
|
|
2905
3022
|
);
|
|
2906
3023
|
const runId = createResponse.run.id;
|
|
@@ -3434,6 +3551,17 @@ You are a GOAL-ORIENTED test agent. Each step describes an INTENT, not a rigid i
|
|
|
3434
3551
|
- Always explain your reasoning when deviating from the original step description
|
|
3435
3552
|
- If an element is below the fold, suggest scrolling first
|
|
3436
3553
|
|
|
3554
|
+
## Intent Safety Guardrails
|
|
3555
|
+
- NEVER click or fill a semantically unrelated element just to make progress.
|
|
3556
|
+
- If the requested target is not present, do NOT guess a random alternative action.
|
|
3557
|
+
- Preserve intent class when adapting. Examples:
|
|
3558
|
+
- form input intent -> editable field
|
|
3559
|
+
- navigation intent -> relevant nav control/link
|
|
3560
|
+
- confirmation intent -> intended submit/confirm action
|
|
3561
|
+
- Prefer this fallback order when target is missing: **scroll -> wait -> navigate/click a clearly relevant entry point for the SAME intent class**.
|
|
3562
|
+
- 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.
|
|
3563
|
+
- If no relevant action is possible on the current page, return confidence 0 and explain why.
|
|
3564
|
+
|
|
3437
3565
|
Always respond with valid JSON only. Do not include any other text.`;
|
|
3438
3566
|
var MAX_MEMORY_ENTRIES = 10;
|
|
3439
3567
|
function formatStepMemory(memory) {
|
|
@@ -3497,7 +3625,9 @@ function generateFillPrompt(fieldDescription, textToEnter, availableElements, me
|
|
|
3497
3625
|
Then type: "${textToEnter}"`,
|
|
3498
3626
|
`IMPORTANT: For fill actions, you MUST target an <input>, <textarea>, or <select> element \u2014 NOT a link, button, div, or card.
|
|
3499
3627
|
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
|
|
3628
|
+
Identify the input field and provide coordinates to click it before typing. Look for data-testid, name, or placeholder attributes for the best selector.
|
|
3629
|
+
If no editable field for this intent is visible, do NOT fill a non-input element.
|
|
3630
|
+
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
3631
|
availableElements,
|
|
3502
3632
|
memoryContext
|
|
3503
3633
|
);
|
|
@@ -3571,6 +3701,9 @@ Consider:
|
|
|
3571
3701
|
- You may need to scroll to find the element
|
|
3572
3702
|
- A modal or overlay may be blocking \u2014 close it first
|
|
3573
3703
|
- The page may be in a different state than expected \u2014 adapt accordingly
|
|
3704
|
+
- Preserve intent class when adapting (input -> input, navigation -> navigation, assertion -> evidence)
|
|
3705
|
+
- Prefer preparatory actions (scroll/wait/open relevant surface) before risky substitutions
|
|
3706
|
+
- NEVER use an unrelated element as a substitute action
|
|
3574
3707
|
- If the intent is truly impossible on this page, set confidence to 0
|
|
3575
3708
|
|
|
3576
3709
|
Respond with the action you would take to achieve the intent.`
|
|
@@ -6044,6 +6177,7 @@ var HybridTestExecutor = class {
|
|
|
6044
6177
|
this.videoDir = null;
|
|
6045
6178
|
this.traceDir = null;
|
|
6046
6179
|
this.traceFilePath = null;
|
|
6180
|
+
this.libraryIndex = null;
|
|
6047
6181
|
this.options = {
|
|
6048
6182
|
baseUrl: options.baseUrl ?? "",
|
|
6049
6183
|
headed: options.headed ?? false,
|
|
@@ -6055,6 +6189,9 @@ var HybridTestExecutor = class {
|
|
|
6055
6189
|
storageStatePath: options.storageStatePath ?? "",
|
|
6056
6190
|
actionModel: options.actionModel ?? DEFAULT_ACTION_MODEL2,
|
|
6057
6191
|
assertionModel: options.assertionModel ?? DEFAULT_ASSERTION_MODEL2,
|
|
6192
|
+
apiBaseUrl: options.apiBaseUrl ?? "",
|
|
6193
|
+
apiToken: options.apiToken ?? "",
|
|
6194
|
+
projectId: options.projectId ?? "",
|
|
6058
6195
|
visionClientOptions: options.visionClientOptions,
|
|
6059
6196
|
onStepComplete: options.onStepComplete,
|
|
6060
6197
|
onTestCaseStart: options.onTestCaseStart,
|
|
@@ -6268,16 +6405,17 @@ var HybridTestExecutor = class {
|
|
|
6268
6405
|
* Execute a single step with hybrid logic
|
|
6269
6406
|
*/
|
|
6270
6407
|
async executeStep(step) {
|
|
6271
|
-
const
|
|
6408
|
+
const enrichedStep = this.enrichStepFromLibrary(step);
|
|
6409
|
+
const hasSelectors = (enrichedStep.target?.suggestedStrategies?.length ?? 0) > 0;
|
|
6272
6410
|
const hasAI = this.visionClient && this.aiExecutor;
|
|
6273
|
-
if (
|
|
6274
|
-
return this.executeNavigate(
|
|
6411
|
+
if (enrichedStep.action === "navigate" && enrichedStep.value) {
|
|
6412
|
+
return this.executeNavigate(enrichedStep);
|
|
6275
6413
|
}
|
|
6276
6414
|
if (hasSelectors) {
|
|
6277
|
-
const selectorResult = await this.trySelector(
|
|
6415
|
+
const selectorResult = await this.trySelector(enrichedStep);
|
|
6278
6416
|
if (selectorResult.success) {
|
|
6279
6417
|
const result3 = {
|
|
6280
|
-
stepId:
|
|
6418
|
+
stepId: enrichedStep.id,
|
|
6281
6419
|
status: "passed",
|
|
6282
6420
|
method: "selector",
|
|
6283
6421
|
duration: selectorResult.duration,
|
|
@@ -6287,22 +6425,12 @@ var HybridTestExecutor = class {
|
|
|
6287
6425
|
return result3;
|
|
6288
6426
|
}
|
|
6289
6427
|
if (hasAI) {
|
|
6290
|
-
this.options.onFallback?.(
|
|
6291
|
-
const aiLog = await this.executeWithAI(
|
|
6428
|
+
this.options.onFallback?.(enrichedStep.id, selectorResult.error || "Selector failed");
|
|
6429
|
+
const aiLog = await this.executeWithAI(enrichedStep);
|
|
6292
6430
|
if (aiLog?.success) {
|
|
6293
|
-
|
|
6294
|
-
if (this.options.learnFromFallback && aiLog.element) {
|
|
6295
|
-
const strategies = generateSelectors(aiLog.element);
|
|
6296
|
-
if (strategies.length > 0) {
|
|
6297
|
-
learnedSelector = {
|
|
6298
|
-
stepId: step.id,
|
|
6299
|
-
elementDescription: aiLog.action.elementDescription || "",
|
|
6300
|
-
strategies
|
|
6301
|
-
};
|
|
6302
|
-
}
|
|
6303
|
-
}
|
|
6431
|
+
const learnedSelector = this.learnFromAIResult(enrichedStep.id, aiLog);
|
|
6304
6432
|
const result4 = {
|
|
6305
|
-
stepId:
|
|
6433
|
+
stepId: enrichedStep.id,
|
|
6306
6434
|
status: "passed",
|
|
6307
6435
|
method: "fallback",
|
|
6308
6436
|
duration: (selectorResult.duration || 0) + aiLog.duration,
|
|
@@ -6315,7 +6443,7 @@ var HybridTestExecutor = class {
|
|
|
6315
6443
|
return result4;
|
|
6316
6444
|
}
|
|
6317
6445
|
const result3 = {
|
|
6318
|
-
stepId:
|
|
6446
|
+
stepId: enrichedStep.id,
|
|
6319
6447
|
status: "failed",
|
|
6320
6448
|
method: "fallback",
|
|
6321
6449
|
duration: (selectorResult.duration || 0) + (aiLog?.duration || 0),
|
|
@@ -6328,7 +6456,7 @@ var HybridTestExecutor = class {
|
|
|
6328
6456
|
return result3;
|
|
6329
6457
|
}
|
|
6330
6458
|
const result2 = {
|
|
6331
|
-
stepId:
|
|
6459
|
+
stepId: enrichedStep.id,
|
|
6332
6460
|
status: "failed",
|
|
6333
6461
|
method: "selector",
|
|
6334
6462
|
duration: selectorResult.duration,
|
|
@@ -6340,20 +6468,10 @@ var HybridTestExecutor = class {
|
|
|
6340
6468
|
return result2;
|
|
6341
6469
|
}
|
|
6342
6470
|
if (hasAI) {
|
|
6343
|
-
const aiLog = await this.executeWithAI(
|
|
6344
|
-
|
|
6345
|
-
if (aiLog?.success && this.options.learnFromFallback && aiLog.element) {
|
|
6346
|
-
const strategies = generateSelectors(aiLog.element);
|
|
6347
|
-
if (strategies.length > 0) {
|
|
6348
|
-
learnedSelector = {
|
|
6349
|
-
stepId: step.id,
|
|
6350
|
-
elementDescription: aiLog.action.elementDescription || "",
|
|
6351
|
-
strategies
|
|
6352
|
-
};
|
|
6353
|
-
}
|
|
6354
|
-
}
|
|
6471
|
+
const aiLog = await this.executeWithAI(enrichedStep);
|
|
6472
|
+
const learnedSelector = aiLog?.success ? this.learnFromAIResult(enrichedStep.id, aiLog) : void 0;
|
|
6355
6473
|
const result2 = {
|
|
6356
|
-
stepId:
|
|
6474
|
+
stepId: enrichedStep.id,
|
|
6357
6475
|
status: aiLog?.success ? "passed" : "failed",
|
|
6358
6476
|
method: "ai",
|
|
6359
6477
|
duration: aiLog?.duration || 0,
|
|
@@ -6365,7 +6483,7 @@ var HybridTestExecutor = class {
|
|
|
6365
6483
|
return result2;
|
|
6366
6484
|
}
|
|
6367
6485
|
const result = {
|
|
6368
|
-
stepId:
|
|
6486
|
+
stepId: enrichedStep.id,
|
|
6369
6487
|
status: "failed",
|
|
6370
6488
|
method: "selector",
|
|
6371
6489
|
duration: 0,
|
|
@@ -6470,6 +6588,7 @@ var HybridTestExecutor = class {
|
|
|
6470
6588
|
async executeTestPlan(testPlan, options) {
|
|
6471
6589
|
const sequential = options?.sequential ?? true;
|
|
6472
6590
|
const results = [];
|
|
6591
|
+
await this.fetchLibraryIndex();
|
|
6473
6592
|
if (this.options.baseUrl && this.page) {
|
|
6474
6593
|
await this.page.goto(this.options.baseUrl, {
|
|
6475
6594
|
waitUntil: "domcontentloaded",
|
|
@@ -6493,6 +6612,174 @@ var HybridTestExecutor = class {
|
|
|
6493
6612
|
}
|
|
6494
6613
|
return results;
|
|
6495
6614
|
}
|
|
6615
|
+
/**
|
|
6616
|
+
* Attempt to learn selectors from a successful AI execution.
|
|
6617
|
+
* Returns a LearnedSelector if strategies were generated, undefined otherwise.
|
|
6618
|
+
*/
|
|
6619
|
+
learnFromAIResult(stepId, aiLog) {
|
|
6620
|
+
if (!this.options.learnFromFallback || !aiLog.element) return void 0;
|
|
6621
|
+
const strategies = generateSelectors(aiLog.element);
|
|
6622
|
+
if (strategies.length === 0) return void 0;
|
|
6623
|
+
const elementDescription = aiLog.action.elementDescription || "";
|
|
6624
|
+
if (this.page) {
|
|
6625
|
+
const pageUrl = this.page.url();
|
|
6626
|
+
const tagName = aiLog.element.tagName?.toLowerCase();
|
|
6627
|
+
this.learnToLibrary(pageUrl, elementDescription, tagName, strategies).catch(() => {
|
|
6628
|
+
});
|
|
6629
|
+
}
|
|
6630
|
+
return { stepId, elementDescription, strategies };
|
|
6631
|
+
}
|
|
6632
|
+
/**
|
|
6633
|
+
* Call the selector-learn API to persist learned selectors to the library.
|
|
6634
|
+
* Fire-and-forget: failures are logged but do not affect execution.
|
|
6635
|
+
*/
|
|
6636
|
+
async learnToLibrary(pageUrl, elementDescription, elementRole, strategies) {
|
|
6637
|
+
const { apiBaseUrl, apiToken, projectId } = this.options;
|
|
6638
|
+
if (!apiBaseUrl || !apiToken || !projectId) return;
|
|
6639
|
+
try {
|
|
6640
|
+
const url = `${apiBaseUrl}/api/projects/${projectId}/selector-learn`;
|
|
6641
|
+
await fetch(url, {
|
|
6642
|
+
method: "POST",
|
|
6643
|
+
headers: {
|
|
6644
|
+
"Content-Type": "application/json",
|
|
6645
|
+
Authorization: `Bearer ${apiToken}`,
|
|
6646
|
+
"x-api-key": apiToken
|
|
6647
|
+
},
|
|
6648
|
+
body: JSON.stringify({
|
|
6649
|
+
pageUrl,
|
|
6650
|
+
learnedElements: [
|
|
6651
|
+
{
|
|
6652
|
+
elementDescription,
|
|
6653
|
+
elementRole,
|
|
6654
|
+
strategies
|
|
6655
|
+
}
|
|
6656
|
+
]
|
|
6657
|
+
}),
|
|
6658
|
+
signal: AbortSignal.timeout(1e4)
|
|
6659
|
+
});
|
|
6660
|
+
} catch {
|
|
6661
|
+
}
|
|
6662
|
+
}
|
|
6663
|
+
/**
|
|
6664
|
+
* Fetch the full library export and cache it for the duration of plan execution.
|
|
6665
|
+
* Called once before executeTestPlan; failures are non-fatal.
|
|
6666
|
+
*/
|
|
6667
|
+
async fetchLibraryIndex() {
|
|
6668
|
+
const { apiBaseUrl, apiToken, projectId } = this.options;
|
|
6669
|
+
if (!apiBaseUrl || !apiToken || !projectId) return;
|
|
6670
|
+
try {
|
|
6671
|
+
const url = `${apiBaseUrl}/api/projects/${projectId}/selector-library/export`;
|
|
6672
|
+
const response = await fetch(url, {
|
|
6673
|
+
method: "GET",
|
|
6674
|
+
headers: {
|
|
6675
|
+
Authorization: `Bearer ${apiToken}`,
|
|
6676
|
+
"x-api-key": apiToken
|
|
6677
|
+
},
|
|
6678
|
+
signal: AbortSignal.timeout(15e3)
|
|
6679
|
+
});
|
|
6680
|
+
if (!response.ok) return;
|
|
6681
|
+
const json = await response.json();
|
|
6682
|
+
const exportData = json.data ?? json;
|
|
6683
|
+
if (!Array.isArray(exportData?.pages)) return;
|
|
6684
|
+
const pages = exportData.pages.map((p) => ({
|
|
6685
|
+
urlPattern: p.urlPattern,
|
|
6686
|
+
elements: (p.sections ?? []).flatMap(
|
|
6687
|
+
(s) => (s.elements ?? []).map((e) => ({
|
|
6688
|
+
name: e.name,
|
|
6689
|
+
aliases: e.aliases ?? [],
|
|
6690
|
+
elementRole: e.elementRole ?? null,
|
|
6691
|
+
strategies: (e.strategies ?? []).map((st) => ({
|
|
6692
|
+
type: st.type,
|
|
6693
|
+
value: st.value,
|
|
6694
|
+
playwrightLocator: st.playwrightLocator,
|
|
6695
|
+
confidence: st.confidence ?? 0.8,
|
|
6696
|
+
rank: st.rank ?? 0
|
|
6697
|
+
}))
|
|
6698
|
+
}))
|
|
6699
|
+
)
|
|
6700
|
+
}));
|
|
6701
|
+
this.libraryIndex = { pages, fetchedAt: Date.now() };
|
|
6702
|
+
} catch {
|
|
6703
|
+
}
|
|
6704
|
+
}
|
|
6705
|
+
/**
|
|
6706
|
+
* Enrich a test step's suggestedStrategies using the cached library index.
|
|
6707
|
+
* Finds matching elements by description and injects their strategies.
|
|
6708
|
+
*/
|
|
6709
|
+
enrichStepFromLibrary(step) {
|
|
6710
|
+
if (!this.libraryIndex) return step;
|
|
6711
|
+
if (step.action === "navigate") return step;
|
|
6712
|
+
const description = step.target?.elementDescription || step.description;
|
|
6713
|
+
if (!description) return step;
|
|
6714
|
+
const currentUrl = this.page?.url() ?? "";
|
|
6715
|
+
const descLower = description.toLowerCase().trim();
|
|
6716
|
+
let matchingPages = this.libraryIndex.pages;
|
|
6717
|
+
if (currentUrl) {
|
|
6718
|
+
try {
|
|
6719
|
+
const urlPath = new URL(currentUrl).pathname;
|
|
6720
|
+
const exactPageMatch = matchingPages.filter(
|
|
6721
|
+
(p) => urlPath === p.urlPattern || urlPath.startsWith(p.urlPattern)
|
|
6722
|
+
);
|
|
6723
|
+
if (exactPageMatch.length > 0) {
|
|
6724
|
+
matchingPages = exactPageMatch;
|
|
6725
|
+
}
|
|
6726
|
+
} catch {
|
|
6727
|
+
}
|
|
6728
|
+
}
|
|
6729
|
+
const allElements = matchingPages.flatMap((p) => p.elements);
|
|
6730
|
+
if (allElements.length === 0) return step;
|
|
6731
|
+
let bestMatch = null;
|
|
6732
|
+
let bestScore = 0;
|
|
6733
|
+
const THRESHOLD = 0.7;
|
|
6734
|
+
for (const el of allElements) {
|
|
6735
|
+
let score = 0;
|
|
6736
|
+
const elNameLower = el.name.toLowerCase();
|
|
6737
|
+
if (descLower === elNameLower) {
|
|
6738
|
+
score = 1;
|
|
6739
|
+
} else if (el.aliases.some((a) => a.toLowerCase() === descLower)) {
|
|
6740
|
+
score = 0.95;
|
|
6741
|
+
} else if (elNameLower.includes(descLower) || descLower.includes(elNameLower)) {
|
|
6742
|
+
score = 0.8;
|
|
6743
|
+
} else {
|
|
6744
|
+
const descWords = descLower.split(/\s+/);
|
|
6745
|
+
const nameWords = elNameLower.split(/\s+/);
|
|
6746
|
+
const overlap = descWords.filter((w) => nameWords.includes(w)).length;
|
|
6747
|
+
const maxWords = Math.max(descWords.length, nameWords.length);
|
|
6748
|
+
if (maxWords > 0) {
|
|
6749
|
+
score = 0.6 * (overlap / maxWords);
|
|
6750
|
+
}
|
|
6751
|
+
}
|
|
6752
|
+
if (el.elementRole && descLower.includes(el.elementRole.toLowerCase())) {
|
|
6753
|
+
score = Math.min(1, score + 0.1);
|
|
6754
|
+
}
|
|
6755
|
+
if (score > bestScore) {
|
|
6756
|
+
bestScore = score;
|
|
6757
|
+
bestMatch = el;
|
|
6758
|
+
}
|
|
6759
|
+
}
|
|
6760
|
+
if (!bestMatch || bestScore < THRESHOLD) return step;
|
|
6761
|
+
const libraryStrategies = bestMatch.strategies.sort((a, b) => a.rank - b.rank).map((s) => ({
|
|
6762
|
+
type: s.type,
|
|
6763
|
+
value: s.value,
|
|
6764
|
+
confidence: s.confidence,
|
|
6765
|
+
playwrightLocator: s.playwrightLocator
|
|
6766
|
+
}));
|
|
6767
|
+
if (libraryStrategies.length === 0) return step;
|
|
6768
|
+
const existingStrategies = step.target?.suggestedStrategies ?? [];
|
|
6769
|
+
const existingKeys = new Set(existingStrategies.map((s) => `${s.type}:${s.value}`));
|
|
6770
|
+
const mergedStrategies = [
|
|
6771
|
+
...libraryStrategies.filter((s) => !existingKeys.has(`${s.type}:${s.value}`)),
|
|
6772
|
+
...existingStrategies
|
|
6773
|
+
];
|
|
6774
|
+
return {
|
|
6775
|
+
...step,
|
|
6776
|
+
target: {
|
|
6777
|
+
elementDescription: step.target?.elementDescription || description,
|
|
6778
|
+
suggestedStrategies: mergedStrategies,
|
|
6779
|
+
elementId: step.target?.elementId
|
|
6780
|
+
}
|
|
6781
|
+
};
|
|
6782
|
+
}
|
|
6496
6783
|
/**
|
|
6497
6784
|
* Convert hybrid results to standard StepResult format
|
|
6498
6785
|
*/
|
|
@@ -6719,8 +7006,72 @@ function inferRoleFromDescription(description) {
|
|
|
6719
7006
|
}
|
|
6720
7007
|
return null;
|
|
6721
7008
|
}
|
|
7009
|
+
function createLocatorFromPlaywrightString(page, playwrightLocator) {
|
|
7010
|
+
try {
|
|
7011
|
+
const testidMatch = playwrightLocator.match(/^getByTestId\(['"](.+?)['"]\)$/);
|
|
7012
|
+
if (testidMatch) {
|
|
7013
|
+
return {
|
|
7014
|
+
locator: page.getByTestId(testidMatch[1]),
|
|
7015
|
+
selectorString: `testid="${testidMatch[1]}"`
|
|
7016
|
+
};
|
|
7017
|
+
}
|
|
7018
|
+
const roleWithNameMatch = playwrightLocator.match(
|
|
7019
|
+
/^getByRole\(['"](.+?)['"],\s*\{\s*name:\s*['"](.+?)['"]\s*\}\)$/
|
|
7020
|
+
);
|
|
7021
|
+
if (roleWithNameMatch) {
|
|
7022
|
+
return {
|
|
7023
|
+
locator: page.getByRole(roleWithNameMatch[1], {
|
|
7024
|
+
name: roleWithNameMatch[2]
|
|
7025
|
+
}),
|
|
7026
|
+
selectorString: `role=${roleWithNameMatch[1]}[name="${roleWithNameMatch[2]}"]`
|
|
7027
|
+
};
|
|
7028
|
+
}
|
|
7029
|
+
const roleMatch = playwrightLocator.match(/^getByRole\(['"](.+?)['"]\)$/);
|
|
7030
|
+
if (roleMatch) {
|
|
7031
|
+
return {
|
|
7032
|
+
locator: page.getByRole(roleMatch[1]),
|
|
7033
|
+
selectorString: `role=${roleMatch[1]}`
|
|
7034
|
+
};
|
|
7035
|
+
}
|
|
7036
|
+
const labelMatch = playwrightLocator.match(/^getByLabel\(['"](.+?)['"]\)$/);
|
|
7037
|
+
if (labelMatch) {
|
|
7038
|
+
return {
|
|
7039
|
+
locator: page.getByLabel(labelMatch[1]),
|
|
7040
|
+
selectorString: `label="${labelMatch[1]}"`
|
|
7041
|
+
};
|
|
7042
|
+
}
|
|
7043
|
+
const placeholderMatch = playwrightLocator.match(/^getByPlaceholder\(['"](.+?)['"]\)$/);
|
|
7044
|
+
if (placeholderMatch) {
|
|
7045
|
+
return {
|
|
7046
|
+
locator: page.getByPlaceholder(placeholderMatch[1]),
|
|
7047
|
+
selectorString: `placeholder="${placeholderMatch[1]}"`
|
|
7048
|
+
};
|
|
7049
|
+
}
|
|
7050
|
+
const textMatch = playwrightLocator.match(/^getByText\(['"](.+?)['"]\)$/);
|
|
7051
|
+
if (textMatch) {
|
|
7052
|
+
return {
|
|
7053
|
+
locator: page.getByText(textMatch[1]),
|
|
7054
|
+
selectorString: `text="${textMatch[1]}"`
|
|
7055
|
+
};
|
|
7056
|
+
}
|
|
7057
|
+
const locatorMatch = playwrightLocator.match(/^locator\(['"](.+?)['"]\)$/);
|
|
7058
|
+
if (locatorMatch) {
|
|
7059
|
+
return {
|
|
7060
|
+
locator: page.locator(locatorMatch[1]),
|
|
7061
|
+
selectorString: `css=${locatorMatch[1]}`
|
|
7062
|
+
};
|
|
7063
|
+
}
|
|
7064
|
+
return null;
|
|
7065
|
+
} catch {
|
|
7066
|
+
return null;
|
|
7067
|
+
}
|
|
7068
|
+
}
|
|
6722
7069
|
async function createLocatorForStrategy(page, strategy, options) {
|
|
6723
7070
|
const { exact = false } = options;
|
|
7071
|
+
if (strategy.playwrightLocator) {
|
|
7072
|
+
const result = createLocatorFromPlaywrightString(page, strategy.playwrightLocator);
|
|
7073
|
+
if (result) return result;
|
|
7074
|
+
}
|
|
6724
7075
|
switch (strategy.type) {
|
|
6725
7076
|
case "role": {
|
|
6726
7077
|
const [role, ...nameParts] = strategy.value.split(":");
|
|
@@ -6799,7 +7150,7 @@ function sortStrategies(strategies) {
|
|
|
6799
7150
|
return b.confidence - a.confidence;
|
|
6800
7151
|
});
|
|
6801
7152
|
}
|
|
6802
|
-
async function
|
|
7153
|
+
async function resolveSelector2(page, hint, options = {}) {
|
|
6803
7154
|
const { timeout = 5e3, maxAttempts } = options;
|
|
6804
7155
|
const attemptedStrategies = [];
|
|
6805
7156
|
const sortedStrategies = sortStrategies(hint.suggestedStrategies);
|
|
@@ -6951,7 +7302,7 @@ async function resolveTarget(page, step, timeout) {
|
|
|
6951
7302
|
step.target.elementDescription
|
|
6952
7303
|
)
|
|
6953
7304
|
};
|
|
6954
|
-
return
|
|
7305
|
+
return resolveSelector2(page, hint, { timeout });
|
|
6955
7306
|
}
|
|
6956
7307
|
async function handleNavigate(page, step, options) {
|
|
6957
7308
|
const { timeout = 3e4 } = options;
|
|
@@ -7629,6 +7980,7 @@ async function executeWithSelectors(testPlan, options) {
|
|
|
7629
7980
|
|
|
7630
7981
|
// src/commands/run/executors/hybrid-executor.ts
|
|
7631
7982
|
import chalk13 from "chalk";
|
|
7983
|
+
init_config();
|
|
7632
7984
|
async function executeWithHybrid(testPlan, options) {
|
|
7633
7985
|
const { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential } = options;
|
|
7634
7986
|
const config = await getCliConfig().catch(() => null);
|
|
@@ -7640,6 +7992,8 @@ async function executeWithHybrid(testPlan, options) {
|
|
|
7640
7992
|
}
|
|
7641
7993
|
const learnedSelectors = [];
|
|
7642
7994
|
const modelOverride = process.env.AI_MODEL;
|
|
7995
|
+
const apiBaseUrl = getApiUrl();
|
|
7996
|
+
const apiToken = getToken();
|
|
7643
7997
|
const hybridExecutor = new HybridTestExecutor({
|
|
7644
7998
|
baseUrl: url,
|
|
7645
7999
|
headed,
|
|
@@ -7651,6 +8005,10 @@ async function executeWithHybrid(testPlan, options) {
|
|
|
7651
8005
|
storageStatePath,
|
|
7652
8006
|
actionModel: modelOverride || void 0,
|
|
7653
8007
|
assertionModel: modelOverride || void 0,
|
|
8008
|
+
// Selector Library integration: AI agent will call /selector-learn after successful fallbacks
|
|
8009
|
+
apiBaseUrl: apiBaseUrl || void 0,
|
|
8010
|
+
apiToken: apiToken || void 0,
|
|
8011
|
+
projectId: options.projectId || void 0,
|
|
7654
8012
|
visionClientOptions: apiKey ? {
|
|
7655
8013
|
apiKey,
|
|
7656
8014
|
timeout: 3e4
|
|
@@ -7962,8 +8320,8 @@ function displayHybridSummary(summary, results, isPretty) {
|
|
|
7962
8320
|
// src/commands/run/executors/selector-learning.ts
|
|
7963
8321
|
import chalk15 from "chalk";
|
|
7964
8322
|
async function handleAISelectorLearning(learnedSelectors, options) {
|
|
7965
|
-
const { projectId,
|
|
7966
|
-
if (!
|
|
8323
|
+
const { projectId, learnSelectors: shouldLearn, yes, verbose, isPretty, targetUrl } = options;
|
|
8324
|
+
if (!shouldLearn) {
|
|
7967
8325
|
return;
|
|
7968
8326
|
}
|
|
7969
8327
|
if (learnedSelectors.length === 0) {
|
|
@@ -7975,41 +8333,26 @@ async function handleAISelectorLearning(learnedSelectors, options) {
|
|
|
7975
8333
|
if (isPretty) {
|
|
7976
8334
|
displayLearnedSelectors(learnedSelectors, verbose);
|
|
7977
8335
|
}
|
|
7978
|
-
if (projectId
|
|
8336
|
+
if (projectId) {
|
|
7979
8337
|
let shouldSave = yes;
|
|
7980
8338
|
if (!shouldSave && isPretty) {
|
|
7981
8339
|
shouldSave = await promptConfirmation(
|
|
7982
|
-
chalk15.yellow("Save learned selectors to
|
|
8340
|
+
chalk15.yellow("Save learned selectors to library?")
|
|
7983
8341
|
);
|
|
7984
8342
|
}
|
|
7985
8343
|
if (shouldSave) {
|
|
7986
|
-
|
|
7987
|
-
try {
|
|
7988
|
-
const saveResult = await saveLearnedSelectors(
|
|
7989
|
-
projectId,
|
|
7990
|
-
testCaseId,
|
|
7991
|
-
learnedSelectors
|
|
7992
|
-
);
|
|
7993
|
-
saveSpinner?.succeed(
|
|
7994
|
-
`Saved selectors for ${saveResult.updatedSteps} step${saveResult.updatedSteps === 1 ? "" : "s"}`
|
|
7995
|
-
);
|
|
7996
|
-
} catch (saveError) {
|
|
7997
|
-
saveSpinner?.fail("Failed to save learned selectors");
|
|
7998
|
-
if (verbose && saveError instanceof Error) {
|
|
7999
|
-
console.warn(chalk15.yellow(` ${saveError.message}`));
|
|
8000
|
-
}
|
|
8001
|
-
}
|
|
8344
|
+
await saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose);
|
|
8002
8345
|
} else if (isPretty) {
|
|
8003
8346
|
log.dim("Selector learning skipped.");
|
|
8004
8347
|
}
|
|
8005
8348
|
} else if (isPretty) {
|
|
8006
|
-
log.dim("Selectors displayed but not saved (no
|
|
8007
|
-
log.dim("Use --project
|
|
8349
|
+
log.dim("Selectors displayed but not saved (no project configured).");
|
|
8350
|
+
log.dim("Use --project to save selectors to the library.");
|
|
8008
8351
|
}
|
|
8009
8352
|
}
|
|
8010
8353
|
async function handleHybridSelectorLearning(learnedSelectors, options) {
|
|
8011
|
-
const { projectId,
|
|
8012
|
-
if (!
|
|
8354
|
+
const { projectId, learnSelectors: shouldLearn, yes, verbose, isPretty, targetUrl } = options;
|
|
8355
|
+
if (!shouldLearn) {
|
|
8013
8356
|
return;
|
|
8014
8357
|
}
|
|
8015
8358
|
if (learnedSelectors.length === 0) {
|
|
@@ -8021,36 +8364,50 @@ async function handleHybridSelectorLearning(learnedSelectors, options) {
|
|
|
8021
8364
|
if (isPretty) {
|
|
8022
8365
|
displayLearnedSelectors(learnedSelectors, verbose);
|
|
8023
8366
|
}
|
|
8024
|
-
if (projectId
|
|
8367
|
+
if (projectId) {
|
|
8025
8368
|
let shouldSave = yes;
|
|
8026
8369
|
if (!shouldSave && isPretty) {
|
|
8027
8370
|
shouldSave = await promptConfirmation(
|
|
8028
|
-
chalk15.yellow("Save learned selectors to
|
|
8371
|
+
chalk15.yellow("Save learned selectors to library?")
|
|
8029
8372
|
);
|
|
8030
8373
|
}
|
|
8031
8374
|
if (shouldSave) {
|
|
8032
|
-
|
|
8033
|
-
try {
|
|
8034
|
-
const saveResult = await saveLearnedSelectors(
|
|
8035
|
-
projectId,
|
|
8036
|
-
testCaseId,
|
|
8037
|
-
learnedSelectors
|
|
8038
|
-
);
|
|
8039
|
-
saveSpinner?.succeed(
|
|
8040
|
-
`Saved selectors for ${saveResult.updatedSteps} step${saveResult.updatedSteps === 1 ? "" : "s"}`
|
|
8041
|
-
);
|
|
8042
|
-
} catch (saveError) {
|
|
8043
|
-
saveSpinner?.fail("Failed to save learned selectors");
|
|
8044
|
-
if (verbose && saveError instanceof Error) {
|
|
8045
|
-
console.warn(chalk15.yellow(` ${saveError.message}`));
|
|
8046
|
-
}
|
|
8047
|
-
}
|
|
8375
|
+
await saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose);
|
|
8048
8376
|
} else if (isPretty) {
|
|
8049
8377
|
log.dim("Selector learning skipped.");
|
|
8050
8378
|
}
|
|
8051
8379
|
} else if (isPretty) {
|
|
8052
|
-
log.dim("Selectors displayed but not saved (no
|
|
8053
|
-
log.dim("Use --project
|
|
8380
|
+
log.dim("Selectors displayed but not saved (no project configured).");
|
|
8381
|
+
log.dim("Use --project to save selectors to the library.");
|
|
8382
|
+
}
|
|
8383
|
+
}
|
|
8384
|
+
async function saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose) {
|
|
8385
|
+
const saveSpinner = isPretty ? createSpinner("Saving selectors to library...").start() : null;
|
|
8386
|
+
try {
|
|
8387
|
+
const elements = learnedSelectors.map((ls) => ({
|
|
8388
|
+
elementDescription: ls.elementDescription || "Unknown element",
|
|
8389
|
+
strategies: ls.strategies.map((s) => ({
|
|
8390
|
+
type: s.type || "css",
|
|
8391
|
+
value: s.value || "",
|
|
8392
|
+
playwrightLocator: s.playwrightLocator || s.value || "",
|
|
8393
|
+
confidence: s.confidence ?? 0.8
|
|
8394
|
+
}))
|
|
8395
|
+
}));
|
|
8396
|
+
const validElements = elements.filter((e) => e.strategies.length > 0);
|
|
8397
|
+
const result = await learnSelectors(
|
|
8398
|
+
projectId,
|
|
8399
|
+
targetUrl || "",
|
|
8400
|
+
validElements
|
|
8401
|
+
);
|
|
8402
|
+
const count = result.created.elements + result.updated.elements;
|
|
8403
|
+
saveSpinner?.succeed(
|
|
8404
|
+
`Saved ${count} selector${count === 1 ? "" : "s"} to library`
|
|
8405
|
+
);
|
|
8406
|
+
} catch (saveError) {
|
|
8407
|
+
saveSpinner?.fail("Failed to save selectors to library");
|
|
8408
|
+
if (verbose && saveError instanceof Error) {
|
|
8409
|
+
console.warn(chalk15.yellow(` ${saveError.message}`));
|
|
8410
|
+
}
|
|
8054
8411
|
}
|
|
8055
8412
|
}
|
|
8056
8413
|
|
|
@@ -8070,7 +8427,7 @@ async function runCommand(options) {
|
|
|
8070
8427
|
format = "pretty",
|
|
8071
8428
|
verbose = false,
|
|
8072
8429
|
timeout = 3e4,
|
|
8073
|
-
learnSelectors = false,
|
|
8430
|
+
learnSelectors: learnSelectors2 = false,
|
|
8074
8431
|
yes = false,
|
|
8075
8432
|
noDedup = false,
|
|
8076
8433
|
autoMerge = false,
|
|
@@ -8092,10 +8449,31 @@ async function runCommand(options) {
|
|
|
8092
8449
|
}
|
|
8093
8450
|
const projectId = project || getActiveProject();
|
|
8094
8451
|
let url = rawUrl;
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8452
|
+
let resolvedEnvName = envName || projectConfig?.environment || "local";
|
|
8453
|
+
let envAuth = projectConfig?.auth;
|
|
8454
|
+
const envConfig = resolveEnvironmentConfig(envName || projectConfig?.environment, projectConfig);
|
|
8455
|
+
if (envConfig) {
|
|
8456
|
+
if (!rawUrl) {
|
|
8457
|
+
url = envConfig.baseUrl || rawUrl;
|
|
8458
|
+
}
|
|
8459
|
+
resolvedEnvName = envConfig.envName;
|
|
8460
|
+
if (envConfig.auth) {
|
|
8461
|
+
envAuth = envConfig.auth;
|
|
8462
|
+
}
|
|
8463
|
+
if (envConfig.apiUrl) {
|
|
8464
|
+
overrideApiUrl(envConfig.apiUrl);
|
|
8465
|
+
}
|
|
8466
|
+
if (isPretty) {
|
|
8467
|
+
log.info(`Using environment "${envConfig.envName}" ${chalk16.dim("(local)")}: ${chalk16.cyan(envConfig.baseUrl || "N/A")}`);
|
|
8468
|
+
if (envConfig.apiUrl) {
|
|
8469
|
+
log.info(` API: ${chalk16.cyan(envConfig.apiUrl)}`);
|
|
8470
|
+
}
|
|
8471
|
+
log.newline();
|
|
8472
|
+
}
|
|
8473
|
+
} else if (envName) {
|
|
8474
|
+
try {
|
|
8475
|
+
if (isPretty) {
|
|
8476
|
+
log.info(`Resolving environment "${envName}"...`);
|
|
8099
8477
|
}
|
|
8100
8478
|
const resolvedEnv = projectId ? await resolveEnvironment(envName, projectId) : null;
|
|
8101
8479
|
if (!resolvedEnv) {
|
|
@@ -8145,11 +8523,10 @@ async function runCommand(options) {
|
|
|
8145
8523
|
}
|
|
8146
8524
|
}
|
|
8147
8525
|
let storageStatePath;
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
const
|
|
8151
|
-
const
|
|
8152
|
-
const credentials = { ...authConfig.credentials };
|
|
8526
|
+
if (envAuth) {
|
|
8527
|
+
const statePath = path7.resolve(envAuth.storageStatePath || DEFAULT_STATE_PATH);
|
|
8528
|
+
const maxAge = envAuth.maxAge || DEFAULT_MAX_AGE;
|
|
8529
|
+
const credentials = { ...envAuth.credentials };
|
|
8153
8530
|
if (AuthExecutor.hasFreshState(statePath, maxAge)) {
|
|
8154
8531
|
if (isPretty) {
|
|
8155
8532
|
log.dim(" Using cached auth state");
|
|
@@ -8161,7 +8538,7 @@ async function runCommand(options) {
|
|
|
8161
8538
|
}
|
|
8162
8539
|
try {
|
|
8163
8540
|
const targetUrl = url || projectConfig?.baseUrl || "";
|
|
8164
|
-
storageStatePath = await AuthExecutor.authenticate(
|
|
8541
|
+
storageStatePath = await AuthExecutor.authenticate(envAuth, targetUrl, credentials);
|
|
8165
8542
|
if (isPretty) {
|
|
8166
8543
|
console.log(chalk16.green(" \u2713 Authenticated"));
|
|
8167
8544
|
}
|
|
@@ -8201,7 +8578,9 @@ async function runCommand(options) {
|
|
|
8201
8578
|
generatedFromFile,
|
|
8202
8579
|
fileGeneratedTestCases,
|
|
8203
8580
|
projectConfig,
|
|
8204
|
-
isPretty
|
|
8581
|
+
isPretty,
|
|
8582
|
+
environment: resolvedEnvName,
|
|
8583
|
+
authEnabled: !!envAuth
|
|
8205
8584
|
});
|
|
8206
8585
|
await runTestExecution({
|
|
8207
8586
|
testPlan,
|
|
@@ -8215,7 +8594,7 @@ async function runCommand(options) {
|
|
|
8215
8594
|
reporter,
|
|
8216
8595
|
projectId,
|
|
8217
8596
|
testCaseId,
|
|
8218
|
-
learnSelectors,
|
|
8597
|
+
learnSelectors: learnSelectors2,
|
|
8219
8598
|
yes,
|
|
8220
8599
|
recordVideo,
|
|
8221
8600
|
recordTrace,
|
|
@@ -8272,7 +8651,7 @@ async function handleFileOption(file, projectId, isPretty, verbose, noDedup, aut
|
|
|
8272
8651
|
}
|
|
8273
8652
|
}
|
|
8274
8653
|
async function runTestExecution(options) {
|
|
8275
|
-
const { testPlan, runId, mode, url, headed, timeout, verbose, isPretty, reporter, projectId, testCaseId, learnSelectors, yes, recordVideo, recordTrace, storageStatePath, sequential } = options;
|
|
8654
|
+
const { testPlan, runId, mode, url, headed, timeout, verbose, isPretty, reporter, projectId, testCaseId, learnSelectors: learnSelectors2, yes, recordVideo, recordTrace, storageStatePath, sequential } = options;
|
|
8276
8655
|
if (isPretty) {
|
|
8277
8656
|
log.newline();
|
|
8278
8657
|
const modeLabel = mode === "ai" ? chalk16.magenta("[AI Mode]") : mode === "hybrid" ? chalk16.yellow("[Hybrid Mode]") : chalk16.cyan("[Selector Mode]");
|
|
@@ -8299,15 +8678,15 @@ async function runTestExecution(options) {
|
|
|
8299
8678
|
await submitAICorrections(aiResults.results, testPlan, projectId, isPretty, verbose);
|
|
8300
8679
|
}
|
|
8301
8680
|
const learnedSelectors = extractLearnedSelectors(aiResults.results);
|
|
8302
|
-
await handleAISelectorLearning(learnedSelectors, { projectId, testCaseId, learnSelectors, yes, verbose, isPretty });
|
|
8681
|
+
await handleAISelectorLearning(learnedSelectors, { projectId, testCaseId, learnSelectors: learnSelectors2, yes, verbose, isPretty, targetUrl: url });
|
|
8303
8682
|
} else if (mode === "hybrid") {
|
|
8304
|
-
const hybridResults = await executeWithHybrid(testPlan, { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential });
|
|
8683
|
+
const hybridResults = await executeWithHybrid(testPlan, { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential, projectId });
|
|
8305
8684
|
summary = hybridResults.summary;
|
|
8306
8685
|
videoPath = hybridResults.videoPath;
|
|
8307
8686
|
tracePath = hybridResults.tracePath;
|
|
8308
8687
|
await submitHybridResults(runId, hybridResults.results, isPretty, verbose);
|
|
8309
8688
|
displayHybridSummary(summary, hybridResults.results, isPretty);
|
|
8310
|
-
await handleHybridSelectorLearning(hybridResults.learnedSelectors, { projectId, testCaseId, learnSelectors, yes, verbose, isPretty });
|
|
8689
|
+
await handleHybridSelectorLearning(hybridResults.learnedSelectors, { projectId, testCaseId, learnSelectors: learnSelectors2, yes, verbose, isPretty, targetUrl: url });
|
|
8311
8690
|
} else {
|
|
8312
8691
|
const results = await executeWithSelectors(testPlan, { url, headed, timeout, isPretty, reporter, recordVideo, recordTrace, storageStatePath, sequential });
|
|
8313
8692
|
summary = { passed: results.summary.passed, failed: results.summary.failed, total: results.summary.total };
|
|
@@ -8349,28 +8728,42 @@ async function runTestExecution(options) {
|
|
|
8349
8728
|
if (isPretty) {
|
|
8350
8729
|
if (uploadResult.videoUrl) {
|
|
8351
8730
|
log.plain(`${chalk16.bold("Video URL:")} ${chalk16.cyan(uploadResult.videoUrl)}`);
|
|
8731
|
+
} else if (videoPath) {
|
|
8732
|
+
log.warn("Video upload completed without a Video URL. Keeping local file.");
|
|
8733
|
+
log.dim(`Local video: ${videoPath}`);
|
|
8352
8734
|
}
|
|
8353
8735
|
if (uploadResult.traceUrl) {
|
|
8354
8736
|
log.plain(`${chalk16.bold("Trace URL:")} ${chalk16.cyan(uploadResult.traceUrl)}`);
|
|
8737
|
+
} else if (tracePath) {
|
|
8738
|
+
log.warn("Trace upload completed without a Trace URL. Keeping local file.");
|
|
8739
|
+
log.dim(`Local trace: ${tracePath}`);
|
|
8355
8740
|
}
|
|
8356
8741
|
}
|
|
8357
|
-
if (videoPath) {
|
|
8742
|
+
if (videoPath && uploadResult.videoUrl) {
|
|
8358
8743
|
try {
|
|
8359
8744
|
fs7.unlinkSync(videoPath);
|
|
8360
8745
|
fs7.rmSync(path7.dirname(videoPath), { recursive: true, force: true });
|
|
8361
8746
|
} catch {
|
|
8362
8747
|
}
|
|
8363
8748
|
}
|
|
8364
|
-
if (tracePath) {
|
|
8749
|
+
if (tracePath && uploadResult.traceUrl) {
|
|
8365
8750
|
try {
|
|
8366
8751
|
fs7.unlinkSync(tracePath);
|
|
8367
8752
|
fs7.rmSync(path7.dirname(tracePath), { recursive: true, force: true });
|
|
8368
8753
|
} catch {
|
|
8369
8754
|
}
|
|
8370
8755
|
}
|
|
8371
|
-
} catch {
|
|
8756
|
+
} catch (error) {
|
|
8372
8757
|
if (isPretty) {
|
|
8373
|
-
|
|
8758
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8759
|
+
log.warn("Failed to upload recordings");
|
|
8760
|
+
log.dim(`Reason: ${message}`);
|
|
8761
|
+
if (videoPath) {
|
|
8762
|
+
log.dim(`Local video kept: ${videoPath}`);
|
|
8763
|
+
}
|
|
8764
|
+
if (tracePath) {
|
|
8765
|
+
log.dim(`Local trace kept: ${tracePath}`);
|
|
8766
|
+
}
|
|
8374
8767
|
}
|
|
8375
8768
|
}
|
|
8376
8769
|
}
|
|
@@ -9428,14 +9821,13 @@ async function featuresListCommand() {
|
|
|
9428
9821
|
const maxSlugLen = Math.max(4, ...features.map((f) => f.slug.length));
|
|
9429
9822
|
console.log(chalk24.bold("Features:"));
|
|
9430
9823
|
console.log(
|
|
9431
|
-
` ${chalk24.dim("NAME".padEnd(maxNameLen))} ${chalk24.dim("SLUG".padEnd(maxSlugLen))} ${chalk24.dim("TEST CASES")}
|
|
9824
|
+
` ${chalk24.dim("NAME".padEnd(maxNameLen))} ${chalk24.dim("SLUG".padEnd(maxSlugLen))} ${chalk24.dim("TEST CASES")}`
|
|
9432
9825
|
);
|
|
9433
9826
|
for (const feature of features) {
|
|
9434
9827
|
const name = feature.name.padEnd(maxNameLen);
|
|
9435
9828
|
const slug = feature.slug.padEnd(maxSlugLen);
|
|
9436
9829
|
const tcCount = String(feature.testCaseCount ?? 0).padStart(5);
|
|
9437
|
-
|
|
9438
|
-
console.log(` ${name} ${chalk24.dim(slug)} ${tcCount} ${selCount}`);
|
|
9830
|
+
console.log(` ${name} ${chalk24.dim(slug)} ${tcCount}`);
|
|
9439
9831
|
}
|
|
9440
9832
|
log.newline();
|
|
9441
9833
|
} catch (error) {
|
|
@@ -9496,113 +9888,590 @@ async function featuresAssignCommand(featureSlug, options) {
|
|
|
9496
9888
|
// src/commands/selectors.ts
|
|
9497
9889
|
import * as fs9 from "fs";
|
|
9498
9890
|
import chalk25 from "chalk";
|
|
9499
|
-
|
|
9500
|
-
|
|
9501
|
-
|
|
9502
|
-
|
|
9503
|
-
|
|
9504
|
-
}
|
|
9505
|
-
async function
|
|
9891
|
+
async function findPageByUrl(projectId, urlPattern) {
|
|
9892
|
+
const { pages } = await listSelectorPages(projectId);
|
|
9893
|
+
return pages.find(
|
|
9894
|
+
(p) => p.urlPattern === urlPattern || p.urlPattern === `/${urlPattern.replace(/^\//, "")}`
|
|
9895
|
+
);
|
|
9896
|
+
}
|
|
9897
|
+
async function findElementByName(projectId, elementName, pageUrlPattern) {
|
|
9898
|
+
let pageId;
|
|
9899
|
+
if (pageUrlPattern) {
|
|
9900
|
+
const page = await findPageByUrl(projectId, pageUrlPattern);
|
|
9901
|
+
if (page) pageId = page.id;
|
|
9902
|
+
}
|
|
9903
|
+
const { elements } = await listSelectorElements(projectId, { pageId });
|
|
9904
|
+
return elements.find(
|
|
9905
|
+
(e) => e.name.toLowerCase() === elementName.toLowerCase()
|
|
9906
|
+
);
|
|
9907
|
+
}
|
|
9908
|
+
async function selectorsPagesCommand(options) {
|
|
9506
9909
|
const projectId = requireAuthAndProject();
|
|
9507
|
-
|
|
9910
|
+
if (options.page) {
|
|
9911
|
+
return selectorsPageDetailCommand(projectId, options.page);
|
|
9912
|
+
}
|
|
9913
|
+
const spinner = createSpinner("Fetching selector library...").start();
|
|
9508
9914
|
try {
|
|
9509
|
-
const {
|
|
9510
|
-
featureSlug: options.feature,
|
|
9511
|
-
status: options.status
|
|
9512
|
-
});
|
|
9915
|
+
const { pages } = await listSelectorPages(projectId);
|
|
9513
9916
|
spinner.succeed(
|
|
9514
|
-
`Found ${
|
|
9917
|
+
`Found ${pages.length} page${pages.length === 1 ? "" : "s"}`
|
|
9515
9918
|
);
|
|
9516
9919
|
log.newline();
|
|
9517
|
-
if (
|
|
9518
|
-
log.plain("No
|
|
9920
|
+
if (pages.length === 0) {
|
|
9921
|
+
log.plain("No selector pages found.");
|
|
9519
9922
|
log.newline();
|
|
9520
|
-
log.dim("Run tests to auto-discover selectors
|
|
9923
|
+
log.dim("Run tests to auto-discover selectors, or add pages manually:");
|
|
9924
|
+
log.dim(' testbro selectors add-page --name "Login Page" --url-pattern "/login"');
|
|
9521
9925
|
log.newline();
|
|
9522
9926
|
return;
|
|
9523
9927
|
}
|
|
9524
|
-
const
|
|
9525
|
-
|
|
9526
|
-
|
|
9527
|
-
)
|
|
9528
|
-
|
|
9529
|
-
|
|
9530
|
-
|
|
9928
|
+
const { elements } = await listSelectorElements(projectId);
|
|
9929
|
+
const pageElementCounts = /* @__PURE__ */ new Map();
|
|
9930
|
+
const pageSectionIds = /* @__PURE__ */ new Map();
|
|
9931
|
+
for (const el of elements) {
|
|
9932
|
+
const pageUrl = el.section?.page?.urlPattern;
|
|
9933
|
+
const pageId = el.section?.page?.id;
|
|
9934
|
+
if (pageId) {
|
|
9935
|
+
pageElementCounts.set(pageId, (pageElementCounts.get(pageId) || 0) + 1);
|
|
9936
|
+
if (!pageSectionIds.has(pageId)) pageSectionIds.set(pageId, /* @__PURE__ */ new Set());
|
|
9937
|
+
pageSectionIds.get(pageId).add(el.sectionId);
|
|
9938
|
+
}
|
|
9939
|
+
}
|
|
9940
|
+
const maxNameLen = Math.max(4, ...pages.map((p) => p.name.length));
|
|
9941
|
+
const maxUrlLen = Math.max(11, ...pages.map((p) => p.urlPattern.length));
|
|
9942
|
+
console.log(chalk25.bold("Selector Pages:"));
|
|
9943
|
+
console.log(
|
|
9944
|
+
` ${chalk25.dim("NAME".padEnd(maxNameLen))} ${chalk25.dim("URL PATTERN".padEnd(maxUrlLen))} ${chalk25.dim("SECTIONS")} ${chalk25.dim("ELEMENTS")}`
|
|
9531
9945
|
);
|
|
9532
|
-
|
|
9946
|
+
for (const page of pages) {
|
|
9947
|
+
const name = page.name.padEnd(maxNameLen);
|
|
9948
|
+
const url = page.urlPattern.padEnd(maxUrlLen);
|
|
9949
|
+
const sectionCount = page._count?.sections ?? 0;
|
|
9950
|
+
const elementCount = pageElementCounts.get(page.id) ?? 0;
|
|
9951
|
+
console.log(
|
|
9952
|
+
` ${name} ${chalk25.dim(url)} ${String(sectionCount).padStart(8)} ${String(elementCount).padStart(8)}`
|
|
9953
|
+
);
|
|
9954
|
+
}
|
|
9955
|
+
log.newline();
|
|
9956
|
+
} catch (error) {
|
|
9957
|
+
spinner.fail("Failed to fetch selector library");
|
|
9958
|
+
handleCliError(error);
|
|
9959
|
+
process.exit(1);
|
|
9960
|
+
}
|
|
9961
|
+
}
|
|
9962
|
+
async function selectorsPageDetailCommand(projectId, pageUrlPattern) {
|
|
9963
|
+
const spinner = createSpinner(`Fetching page "${pageUrlPattern}"...`).start();
|
|
9964
|
+
try {
|
|
9965
|
+
const page = await findPageByUrl(projectId, pageUrlPattern);
|
|
9966
|
+
if (!page) {
|
|
9967
|
+
spinner.fail(`Page with URL pattern "${pageUrlPattern}" not found`);
|
|
9968
|
+
return;
|
|
9969
|
+
}
|
|
9970
|
+
const { elements } = await listSelectorElements(projectId, { pageId: page.id });
|
|
9971
|
+
spinner.succeed(`${page.name} (${page.urlPattern})`);
|
|
9972
|
+
log.newline();
|
|
9973
|
+
if (elements.length === 0) {
|
|
9974
|
+
log.plain("No elements found for this page.");
|
|
9975
|
+
log.newline();
|
|
9976
|
+
return;
|
|
9977
|
+
}
|
|
9978
|
+
const sectionMap = /* @__PURE__ */ new Map();
|
|
9979
|
+
for (const el of elements) {
|
|
9980
|
+
const sectionId = el.sectionId;
|
|
9981
|
+
if (!sectionMap.has(sectionId)) sectionMap.set(sectionId, []);
|
|
9982
|
+
sectionMap.get(sectionId).push(el);
|
|
9983
|
+
}
|
|
9984
|
+
const sectionNames = /* @__PURE__ */ new Map();
|
|
9985
|
+
for (const el of elements) {
|
|
9986
|
+
const sectionId = el.sectionId;
|
|
9987
|
+
if (!sectionNames.has(sectionId)) {
|
|
9988
|
+
sectionNames.set(sectionId, sectionId);
|
|
9989
|
+
}
|
|
9990
|
+
}
|
|
9991
|
+
for (const [sectionId, sectionElements] of sectionMap) {
|
|
9992
|
+
const sectionName = sectionNames.get(sectionId) || "Unknown Section";
|
|
9993
|
+
console.log(` ${chalk25.bold.underline(sectionName)}`);
|
|
9994
|
+
for (const el of sectionElements) {
|
|
9995
|
+
const topStrategy = el.strategies[0];
|
|
9996
|
+
const strategyStr = topStrategy ? `${topStrategy.type}="${topStrategy.value}"` : chalk25.dim("no strategies");
|
|
9997
|
+
const statusIcon2 = el.status === "active" ? icons.success : el.status === "broken" ? icons.fail : chalk25.yellow("~");
|
|
9998
|
+
const totalSuccess = el.strategies.reduce((sum, s) => sum + s.successCount, 0);
|
|
9999
|
+
console.log(
|
|
10000
|
+
` ${statusIcon2} ${el.name.padEnd(25)} ${chalk25.dim(strategyStr.padEnd(35))} ${chalk25.dim(`${totalSuccess} successes`)}`
|
|
10001
|
+
);
|
|
10002
|
+
}
|
|
10003
|
+
log.newline();
|
|
10004
|
+
}
|
|
10005
|
+
} catch (error) {
|
|
10006
|
+
spinner.fail("Failed to fetch page details");
|
|
10007
|
+
handleCliError(error);
|
|
10008
|
+
process.exit(1);
|
|
10009
|
+
}
|
|
10010
|
+
}
|
|
10011
|
+
async function selectorsElementsCommand(options) {
|
|
10012
|
+
const projectId = requireAuthAndProject();
|
|
10013
|
+
const spinner = createSpinner("Fetching elements...").start();
|
|
10014
|
+
try {
|
|
10015
|
+
let pageId;
|
|
10016
|
+
if (options.page) {
|
|
10017
|
+
const page = await findPageByUrl(projectId, options.page);
|
|
10018
|
+
if (page) pageId = page.id;
|
|
10019
|
+
}
|
|
10020
|
+
const { elements } = await listSelectorElements(projectId, {
|
|
10021
|
+
pageId,
|
|
10022
|
+
status: options.status,
|
|
10023
|
+
tag: options.tag
|
|
10024
|
+
});
|
|
10025
|
+
spinner.succeed(`Found ${elements.length} element${elements.length === 1 ? "" : "s"}`);
|
|
10026
|
+
log.newline();
|
|
10027
|
+
if (elements.length === 0) {
|
|
10028
|
+
log.plain("No elements found matching the criteria.");
|
|
10029
|
+
log.newline();
|
|
10030
|
+
return;
|
|
10031
|
+
}
|
|
10032
|
+
const maxNameLen = Math.max(4, ...elements.map((e) => e.name.length));
|
|
10033
|
+
console.log(chalk25.bold("Elements:"));
|
|
9533
10034
|
console.log(
|
|
9534
|
-
` ${chalk25.dim("
|
|
10035
|
+
` ${chalk25.dim("NAME".padEnd(maxNameLen))} ${chalk25.dim("PAGE".padEnd(20))} ${chalk25.dim("STATUS".padEnd(10))} ${chalk25.dim("STRATEGIES")} ${chalk25.dim("ROLE")}`
|
|
9535
10036
|
);
|
|
9536
|
-
for (const
|
|
9537
|
-
const
|
|
9538
|
-
const
|
|
9539
|
-
const
|
|
9540
|
-
const
|
|
9541
|
-
const
|
|
9542
|
-
|
|
10037
|
+
for (const el of elements) {
|
|
10038
|
+
const name = el.name.padEnd(maxNameLen);
|
|
10039
|
+
const pageName = (el.section?.page?.urlPattern || "").padEnd(20);
|
|
10040
|
+
const status = el.status.padEnd(10);
|
|
10041
|
+
const stratCount = String(el.strategies.length).padStart(10);
|
|
10042
|
+
const role = el.elementRole || "-";
|
|
10043
|
+
const statusColor = el.status === "active" ? chalk25.green(status) : el.status === "broken" ? chalk25.red(status) : chalk25.yellow(status);
|
|
10044
|
+
console.log(
|
|
10045
|
+
` ${name} ${chalk25.dim(pageName)} ${statusColor} ${stratCount} ${chalk25.dim(role)}`
|
|
10046
|
+
);
|
|
9543
10047
|
}
|
|
9544
10048
|
log.newline();
|
|
9545
10049
|
} catch (error) {
|
|
9546
|
-
spinner.fail("Failed to fetch
|
|
10050
|
+
spinner.fail("Failed to fetch elements");
|
|
9547
10051
|
handleCliError(error);
|
|
9548
10052
|
process.exit(1);
|
|
9549
10053
|
}
|
|
9550
10054
|
}
|
|
9551
|
-
async function
|
|
10055
|
+
async function selectorsFindCommand(description) {
|
|
9552
10056
|
const projectId = requireAuthAndProject();
|
|
9553
|
-
const
|
|
9554
|
-
const label = selectorIds ? `Re-verifying ${selectorIds.length} selector${selectorIds.length === 1 ? "" : "s"}...` : "Re-verifying all selectors...";
|
|
9555
|
-
const spinner = createSpinner(label).start();
|
|
10057
|
+
const spinner = createSpinner("Searching...").start();
|
|
9556
10058
|
try {
|
|
9557
|
-
const result = await
|
|
9558
|
-
|
|
10059
|
+
const result = await resolveSelector(projectId, {
|
|
10060
|
+
url: "/",
|
|
10061
|
+
elementDescription: description
|
|
10062
|
+
});
|
|
10063
|
+
if (!result.matched) {
|
|
10064
|
+
spinner.warn(`No match found (best score: ${((result.matchScore || 0) * 100).toFixed(0)}%)`);
|
|
10065
|
+
log.newline();
|
|
10066
|
+
if (result.element) {
|
|
10067
|
+
const el2 = result.element;
|
|
10068
|
+
log.dim(`Closest: ${el2.name} (score: ${((result.matchScore || 0) * 100).toFixed(0)}%)`);
|
|
10069
|
+
}
|
|
10070
|
+
log.newline();
|
|
10071
|
+
return;
|
|
10072
|
+
}
|
|
10073
|
+
const el = result.element;
|
|
10074
|
+
spinner.succeed(`MATCH (score: ${((result.matchScore || 0) * 100).toFixed(0)}%): ${el.name}`);
|
|
9559
10075
|
log.newline();
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
10076
|
+
if (result.strategies && result.strategies.length > 0) {
|
|
10077
|
+
console.log(chalk25.bold(" Strategies:"));
|
|
10078
|
+
for (const s of result.strategies) {
|
|
10079
|
+
const successInfo = `rank=${s.rank}, confidence=${(s.confidence * 100).toFixed(0)}%`;
|
|
10080
|
+
console.log(
|
|
10081
|
+
` [${s.rank}] ${s.type}="${s.value}" ${chalk25.dim(`(${successInfo})`)}`
|
|
10082
|
+
);
|
|
10083
|
+
}
|
|
10084
|
+
}
|
|
9564
10085
|
log.newline();
|
|
9565
10086
|
} catch (error) {
|
|
9566
|
-
spinner.fail("
|
|
10087
|
+
spinner.fail("Search failed");
|
|
9567
10088
|
handleCliError(error);
|
|
9568
10089
|
process.exit(1);
|
|
9569
10090
|
}
|
|
9570
10091
|
}
|
|
9571
|
-
async function
|
|
10092
|
+
async function selectorsAddPageCommand(options) {
|
|
9572
10093
|
const projectId = requireAuthAndProject();
|
|
9573
|
-
|
|
9574
|
-
|
|
10094
|
+
if (!options.name || !options.urlPattern) {
|
|
10095
|
+
log.error("Both --name and --url-pattern are required.");
|
|
10096
|
+
process.exit(1);
|
|
10097
|
+
}
|
|
10098
|
+
const spinner = createSpinner("Creating page...").start();
|
|
9575
10099
|
try {
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
10100
|
+
const tags = options.tags ? options.tags.split(",").map((t) => t.trim()) : void 0;
|
|
10101
|
+
const { page } = await createSelectorPage(projectId, {
|
|
10102
|
+
name: options.name,
|
|
10103
|
+
urlPattern: options.urlPattern,
|
|
10104
|
+
tags
|
|
10105
|
+
});
|
|
10106
|
+
spinner.succeed(`Page created: ${page.name} (${page.urlPattern})`);
|
|
10107
|
+
log.newline();
|
|
10108
|
+
} catch (error) {
|
|
10109
|
+
spinner.fail("Failed to create page");
|
|
10110
|
+
handleCliError(error);
|
|
10111
|
+
process.exit(1);
|
|
10112
|
+
}
|
|
10113
|
+
}
|
|
10114
|
+
async function selectorsAddSectionCommand(options) {
|
|
10115
|
+
const projectId = requireAuthAndProject();
|
|
10116
|
+
if (!options.page || !options.name) {
|
|
10117
|
+
log.error("Both --page and --name are required.");
|
|
10118
|
+
process.exit(1);
|
|
10119
|
+
}
|
|
10120
|
+
const spinner = createSpinner("Creating section...").start();
|
|
10121
|
+
try {
|
|
10122
|
+
const page = await findPageByUrl(projectId, options.page);
|
|
10123
|
+
if (!page) {
|
|
10124
|
+
spinner.fail(`Page with URL pattern "${options.page}" not found`);
|
|
10125
|
+
return;
|
|
10126
|
+
}
|
|
10127
|
+
await createSelectorSection(projectId, page.id, {
|
|
10128
|
+
name: options.name
|
|
10129
|
+
});
|
|
10130
|
+
spinner.succeed(`Section created: ${options.name} (in ${page.name})`);
|
|
10131
|
+
log.newline();
|
|
10132
|
+
} catch (error) {
|
|
10133
|
+
spinner.fail("Failed to create section");
|
|
10134
|
+
handleCliError(error);
|
|
10135
|
+
process.exit(1);
|
|
10136
|
+
}
|
|
10137
|
+
}
|
|
10138
|
+
function parseStrategy(strategyStr) {
|
|
10139
|
+
const match = strategyStr.match(/^(\w+)=(.+)$/);
|
|
10140
|
+
if (!match) return null;
|
|
10141
|
+
const type = match[1];
|
|
10142
|
+
const value = match[2];
|
|
10143
|
+
let playwrightLocator;
|
|
10144
|
+
switch (type) {
|
|
10145
|
+
case "label":
|
|
10146
|
+
playwrightLocator = `getByLabel('${value}')`;
|
|
10147
|
+
break;
|
|
10148
|
+
case "testid":
|
|
10149
|
+
playwrightLocator = `getByTestId('${value}')`;
|
|
10150
|
+
break;
|
|
10151
|
+
case "role":
|
|
10152
|
+
playwrightLocator = value.includes(":") ? `getByRole('${value.split(":")[0]}', { name: '${value.split(":")[1]}' })` : `getByRole('${value}')`;
|
|
10153
|
+
break;
|
|
10154
|
+
case "placeholder":
|
|
10155
|
+
playwrightLocator = `getByPlaceholder('${value}')`;
|
|
10156
|
+
break;
|
|
10157
|
+
case "text":
|
|
10158
|
+
playwrightLocator = `getByText('${value}')`;
|
|
10159
|
+
break;
|
|
10160
|
+
case "css":
|
|
10161
|
+
playwrightLocator = `locator('${value}')`;
|
|
10162
|
+
break;
|
|
10163
|
+
case "id":
|
|
10164
|
+
playwrightLocator = `locator('#${value}')`;
|
|
10165
|
+
break;
|
|
10166
|
+
default:
|
|
10167
|
+
playwrightLocator = `locator('${value}')`;
|
|
10168
|
+
}
|
|
10169
|
+
return { type, value, playwrightLocator };
|
|
10170
|
+
}
|
|
10171
|
+
async function selectorsAddElementCommand(options) {
|
|
10172
|
+
const projectId = requireAuthAndProject();
|
|
10173
|
+
if (!options.page || !options.section || !options.name) {
|
|
10174
|
+
log.error("--page, --section, and --name are required.");
|
|
10175
|
+
process.exit(1);
|
|
10176
|
+
}
|
|
10177
|
+
const spinner = createSpinner("Creating element...").start();
|
|
10178
|
+
try {
|
|
10179
|
+
const page = await findPageByUrl(projectId, options.page);
|
|
10180
|
+
if (!page) {
|
|
10181
|
+
spinner.fail(`Page with URL pattern "${options.page}" not found`);
|
|
10182
|
+
return;
|
|
10183
|
+
}
|
|
10184
|
+
let sectionId;
|
|
10185
|
+
const { section } = await createSelectorSection(projectId, page.id, {
|
|
10186
|
+
name: options.section
|
|
10187
|
+
}).catch(async () => {
|
|
10188
|
+
const { elements: elems } = await listSelectorElements(projectId, { pageId: page.id });
|
|
10189
|
+
throw new Error(`Section "${options.section}" could not be created or found.`);
|
|
10190
|
+
});
|
|
10191
|
+
sectionId = section.id;
|
|
10192
|
+
const strategies = [];
|
|
10193
|
+
const strategyInputs = Array.isArray(options.strategy) ? options.strategy : options.strategy ? [options.strategy] : [];
|
|
10194
|
+
for (const s of strategyInputs) {
|
|
10195
|
+
const parsed = parseStrategy(s);
|
|
10196
|
+
if (parsed) {
|
|
10197
|
+
strategies.push({ ...parsed, confidence: 0.8, source: "manual" });
|
|
9583
10198
|
}
|
|
9584
|
-
featureId = feature.id;
|
|
9585
10199
|
}
|
|
9586
|
-
const
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
10200
|
+
const { element } = await createSelectorElement(projectId, {
|
|
10201
|
+
sectionId,
|
|
10202
|
+
name: options.name,
|
|
10203
|
+
elementRole: options.role,
|
|
10204
|
+
strategies: strategies.length > 0 ? strategies : void 0
|
|
9590
10205
|
});
|
|
9591
|
-
|
|
9592
|
-
|
|
9593
|
-
|
|
9594
|
-
|
|
9595
|
-
|
|
10206
|
+
spinner.succeed(`Element created: ${element.name} (${element.strategies.length} strategies)`);
|
|
10207
|
+
log.newline();
|
|
10208
|
+
} catch (error) {
|
|
10209
|
+
spinner.fail("Failed to create element");
|
|
10210
|
+
handleCliError(error);
|
|
10211
|
+
process.exit(1);
|
|
10212
|
+
}
|
|
10213
|
+
}
|
|
10214
|
+
async function selectorsHistoryCommand(options) {
|
|
10215
|
+
const projectId = requireAuthAndProject();
|
|
10216
|
+
if (!options.element) {
|
|
10217
|
+
log.error("--element is required.");
|
|
10218
|
+
process.exit(1);
|
|
10219
|
+
}
|
|
10220
|
+
const spinner = createSpinner("Fetching history...").start();
|
|
10221
|
+
try {
|
|
10222
|
+
const element = await findElementByName(projectId, options.element, options.page);
|
|
10223
|
+
if (!element) {
|
|
10224
|
+
spinner.fail(`Element "${options.element}" not found`);
|
|
10225
|
+
return;
|
|
10226
|
+
}
|
|
10227
|
+
const { history, total } = await getSelectorElementHistory(
|
|
10228
|
+
projectId,
|
|
10229
|
+
element.id,
|
|
10230
|
+
20
|
|
10231
|
+
);
|
|
10232
|
+
spinner.succeed(`History for "${element.name}" (${total} entries)`);
|
|
10233
|
+
log.newline();
|
|
10234
|
+
if (history.length === 0) {
|
|
10235
|
+
log.plain("No history entries found.");
|
|
10236
|
+
log.newline();
|
|
10237
|
+
return;
|
|
10238
|
+
}
|
|
10239
|
+
console.log(chalk25.bold("History:"));
|
|
10240
|
+
for (let i = 0; i < history.length; i++) {
|
|
10241
|
+
const entry = history[i];
|
|
10242
|
+
const date = new Date(entry.createdAt).toLocaleString();
|
|
10243
|
+
const actionLabel = entry.action.replace(/_/g, " ");
|
|
10244
|
+
const changedBy = entry.changedBy || "unknown";
|
|
10245
|
+
const reason = entry.reason ? ` - ${entry.reason}` : "";
|
|
10246
|
+
console.log(
|
|
10247
|
+
` ${chalk25.dim(`#${total - i}`)} ${chalk25.bold(actionLabel)} ${chalk25.dim(date)} by ${chalk25.cyan(changedBy)}${chalk25.dim(reason)}`
|
|
10248
|
+
);
|
|
10249
|
+
console.log(` ${chalk25.dim(`ID: ${entry.id}`)}`);
|
|
10250
|
+
}
|
|
10251
|
+
log.newline();
|
|
10252
|
+
} catch (error) {
|
|
10253
|
+
spinner.fail("Failed to fetch history");
|
|
10254
|
+
handleCliError(error);
|
|
10255
|
+
process.exit(1);
|
|
10256
|
+
}
|
|
10257
|
+
}
|
|
10258
|
+
async function selectorsRollbackCommand(options) {
|
|
10259
|
+
const projectId = requireAuthAndProject();
|
|
10260
|
+
if (!options.element || !options.version) {
|
|
10261
|
+
log.error("--element and --version are required.");
|
|
10262
|
+
process.exit(1);
|
|
10263
|
+
}
|
|
10264
|
+
const spinner = createSpinner("Rolling back...").start();
|
|
10265
|
+
try {
|
|
10266
|
+
const element = await findElementByName(projectId, options.element, options.page);
|
|
10267
|
+
if (!element) {
|
|
10268
|
+
spinner.fail(`Element "${options.element}" not found`);
|
|
10269
|
+
return;
|
|
10270
|
+
}
|
|
10271
|
+
const { history, total } = await getSelectorElementHistory(
|
|
10272
|
+
projectId,
|
|
10273
|
+
element.id,
|
|
10274
|
+
200
|
|
10275
|
+
// fetch enough to find the version
|
|
10276
|
+
);
|
|
10277
|
+
const versionNum = parseInt(options.version, 10);
|
|
10278
|
+
const entryIndex = versionNum - 1;
|
|
10279
|
+
if (entryIndex < 0 || entryIndex >= history.length) {
|
|
10280
|
+
spinner.fail(`Version ${versionNum} not found. Available: 1-${history.length}`);
|
|
10281
|
+
return;
|
|
10282
|
+
}
|
|
10283
|
+
const historyEntry = history[entryIndex];
|
|
10284
|
+
await rollbackSelectorElement(projectId, element.id, historyEntry.id);
|
|
10285
|
+
spinner.succeed(`Rolled back "${element.name}" to version ${versionNum}`);
|
|
10286
|
+
log.newline();
|
|
10287
|
+
} catch (error) {
|
|
10288
|
+
spinner.fail("Rollback failed");
|
|
10289
|
+
handleCliError(error);
|
|
10290
|
+
process.exit(1);
|
|
10291
|
+
}
|
|
10292
|
+
}
|
|
10293
|
+
async function selectorsHealthCommand() {
|
|
10294
|
+
const projectId = requireAuthAndProject();
|
|
10295
|
+
const spinner = createSpinner("Checking selector health...").start();
|
|
10296
|
+
try {
|
|
10297
|
+
const { elements } = await listSelectorElements(projectId);
|
|
10298
|
+
const total = elements.length;
|
|
10299
|
+
const active = elements.filter((e) => e.status === "active").length;
|
|
10300
|
+
const broken = elements.filter((e) => e.status === "broken").length;
|
|
10301
|
+
const deprecated = elements.filter((e) => e.status === "deprecated").length;
|
|
10302
|
+
const now = Date.now();
|
|
10303
|
+
const sevenDaysMs = 7 * 24 * 60 * 60 * 1e3;
|
|
10304
|
+
const stale = elements.filter((e) => {
|
|
10305
|
+
if (e.status === "deprecated") return false;
|
|
10306
|
+
if (e.strategies.length === 0) return true;
|
|
10307
|
+
return e.strategies.every((s) => {
|
|
10308
|
+
if (!s.lastUsedAt) return true;
|
|
10309
|
+
return now - new Date(s.lastUsedAt).getTime() > sevenDaysMs;
|
|
10310
|
+
});
|
|
10311
|
+
});
|
|
10312
|
+
spinner.succeed("Selector Library Health");
|
|
10313
|
+
log.newline();
|
|
10314
|
+
console.log(` ${chalk25.bold("Total Elements:")} ${total}`);
|
|
10315
|
+
console.log(
|
|
10316
|
+
` ${chalk25.green("Active:")} ${active} | ${chalk25.yellow("Stale:")} ${stale.length} | ${chalk25.red("Broken:")} ${broken} | ${chalk25.dim("Deprecated:")} ${deprecated}`
|
|
10317
|
+
);
|
|
10318
|
+
log.newline();
|
|
10319
|
+
if (broken > 0) {
|
|
10320
|
+
console.log(chalk25.bold.red(" BROKEN ELEMENTS:"));
|
|
10321
|
+
const brokenElements = elements.filter((e) => e.status === "broken");
|
|
10322
|
+
for (const el of brokenElements) {
|
|
10323
|
+
const totalSuccess = el.strategies.reduce((sum, s) => sum + s.successCount, 0);
|
|
10324
|
+
const totalFail = el.strategies.reduce((sum, s) => sum + s.failCount, 0);
|
|
10325
|
+
const lastFailed = el.strategies.map((s) => s.lastFailedAt).filter(Boolean).sort().reverse()[0];
|
|
10326
|
+
const failedAgo = lastFailed ? formatTimeAgo(new Date(lastFailed)) : "never";
|
|
10327
|
+
const pageName = el.section?.page?.name || "Unknown";
|
|
10328
|
+
console.log(
|
|
10329
|
+
` ${icons.fail} ${pageName} > ${el.name} ${chalk25.dim(`(${totalSuccess}/${totalSuccess + totalFail} success, last failed ${failedAgo})`)}`
|
|
10330
|
+
);
|
|
10331
|
+
}
|
|
10332
|
+
log.newline();
|
|
10333
|
+
}
|
|
10334
|
+
if (stale.length > 0) {
|
|
10335
|
+
console.log(chalk25.bold.yellow(" STALE ELEMENTS:"));
|
|
10336
|
+
for (const el of stale.slice(0, 10)) {
|
|
10337
|
+
const lastUsed = el.strategies.map((s) => s.lastUsedAt).filter(Boolean).sort().reverse()[0];
|
|
10338
|
+
const usedAgo = lastUsed ? formatTimeAgo(new Date(lastUsed)) : "never used";
|
|
10339
|
+
const pageName = el.section?.page?.name || "Unknown";
|
|
10340
|
+
console.log(
|
|
10341
|
+
` ${icons.warning} ${pageName} > ${el.name} ${chalk25.dim(`(${usedAgo})`)}`
|
|
10342
|
+
);
|
|
10343
|
+
}
|
|
10344
|
+
if (stale.length > 10) {
|
|
10345
|
+
log.dim(` ... and ${stale.length - 10} more`);
|
|
10346
|
+
}
|
|
10347
|
+
log.newline();
|
|
10348
|
+
}
|
|
10349
|
+
} catch (error) {
|
|
10350
|
+
spinner.fail("Failed to check health");
|
|
10351
|
+
handleCliError(error);
|
|
10352
|
+
process.exit(1);
|
|
10353
|
+
}
|
|
10354
|
+
}
|
|
10355
|
+
async function selectorsExportCommand(options) {
|
|
10356
|
+
const projectId = requireAuthAndProject();
|
|
10357
|
+
if (!options.output) {
|
|
10358
|
+
log.error("--output is required.");
|
|
10359
|
+
process.exit(1);
|
|
10360
|
+
}
|
|
10361
|
+
const spinner = createSpinner("Exporting selector library...").start();
|
|
10362
|
+
try {
|
|
10363
|
+
const exportData = await exportSelectorLibrary(projectId);
|
|
10364
|
+
if (options.page) {
|
|
10365
|
+
exportData.pages = exportData.pages.filter(
|
|
10366
|
+
(p) => p.urlPattern === options.page || p.urlPattern === `/${options.page.replace(/^\//, "")}`
|
|
10367
|
+
);
|
|
10368
|
+
}
|
|
10369
|
+
const json = JSON.stringify(exportData, null, 2);
|
|
10370
|
+
fs9.writeFileSync(options.output, json, "utf-8");
|
|
10371
|
+
const pageCount = exportData.pages.length;
|
|
10372
|
+
const elementCount = exportData.pages.reduce(
|
|
10373
|
+
(sum, p) => sum + p.sections.reduce(
|
|
10374
|
+
(sSum, s) => sSum + s.elements.length,
|
|
10375
|
+
0
|
|
10376
|
+
),
|
|
10377
|
+
0
|
|
10378
|
+
);
|
|
10379
|
+
spinner.succeed(
|
|
10380
|
+
`Exported ${pageCount} page${pageCount === 1 ? "" : "s"}, ${elementCount} element${elementCount === 1 ? "" : "s"} to ${options.output}`
|
|
10381
|
+
);
|
|
10382
|
+
log.newline();
|
|
10383
|
+
} catch (error) {
|
|
10384
|
+
spinner.fail("Export failed");
|
|
10385
|
+
handleCliError(error);
|
|
10386
|
+
process.exit(1);
|
|
10387
|
+
}
|
|
10388
|
+
}
|
|
10389
|
+
async function selectorsImportCommand(options) {
|
|
10390
|
+
const projectId = requireAuthAndProject();
|
|
10391
|
+
if (!options.input) {
|
|
10392
|
+
log.error("--input is required.");
|
|
10393
|
+
process.exit(1);
|
|
10394
|
+
}
|
|
10395
|
+
if (!fs9.existsSync(options.input)) {
|
|
10396
|
+
log.error(`File not found: ${options.input}`);
|
|
10397
|
+
process.exit(1);
|
|
10398
|
+
}
|
|
10399
|
+
const spinner = createSpinner("Reading import file...").start();
|
|
10400
|
+
try {
|
|
10401
|
+
const raw = fs9.readFileSync(options.input, "utf-8");
|
|
10402
|
+
const data = JSON.parse(raw);
|
|
10403
|
+
if (!data.version || !Array.isArray(data.pages)) {
|
|
10404
|
+
spinner.fail("Invalid import file format");
|
|
10405
|
+
return;
|
|
10406
|
+
}
|
|
10407
|
+
const pageCount = data.pages.length;
|
|
10408
|
+
const elementCount = data.pages.reduce(
|
|
10409
|
+
(sum, p) => sum + p.sections.reduce(
|
|
10410
|
+
(sSum, s) => sSum + s.elements.length,
|
|
10411
|
+
0
|
|
10412
|
+
),
|
|
10413
|
+
0
|
|
10414
|
+
);
|
|
10415
|
+
const strategyCount = data.pages.reduce(
|
|
10416
|
+
(sum, p) => sum + p.sections.reduce(
|
|
10417
|
+
(sSum, s) => sSum + s.elements.reduce(
|
|
10418
|
+
(eSum, e) => eSum + e.strategies.length,
|
|
10419
|
+
0
|
|
10420
|
+
),
|
|
10421
|
+
0
|
|
10422
|
+
),
|
|
10423
|
+
0
|
|
10424
|
+
);
|
|
10425
|
+
if (options.dryRun) {
|
|
10426
|
+
spinner.succeed("Dry run - no changes will be made");
|
|
10427
|
+
log.newline();
|
|
10428
|
+
console.log(chalk25.bold("Import preview:"));
|
|
10429
|
+
console.log(` Pages: ${pageCount}`);
|
|
10430
|
+
console.log(` Elements: ${elementCount}`);
|
|
10431
|
+
console.log(` Strategies: ${strategyCount}`);
|
|
9596
10432
|
log.newline();
|
|
9597
|
-
|
|
10433
|
+
for (const page of data.pages) {
|
|
10434
|
+
console.log(` ${chalk25.bold(page.name)} (${page.urlPattern})`);
|
|
10435
|
+
for (const section of page.sections) {
|
|
10436
|
+
console.log(` ${section.name}`);
|
|
10437
|
+
for (const element of section.elements) {
|
|
10438
|
+
console.log(` ${element.name} (${element.strategies.length} strategies)`);
|
|
10439
|
+
}
|
|
10440
|
+
}
|
|
10441
|
+
}
|
|
10442
|
+
log.newline();
|
|
10443
|
+
return;
|
|
9598
10444
|
}
|
|
10445
|
+
spinner.text("Importing...");
|
|
10446
|
+
const result = await importSelectorLibrary(projectId, data);
|
|
10447
|
+
spinner.succeed("Import complete");
|
|
10448
|
+
log.newline();
|
|
10449
|
+
console.log(chalk25.bold("Results:"));
|
|
10450
|
+
console.log(` Pages created: ${result.imported.pages}`);
|
|
10451
|
+
console.log(` Sections created: ${result.imported.sections}`);
|
|
10452
|
+
console.log(` Elements created: ${result.imported.elements}`);
|
|
10453
|
+
console.log(` Strategies created: ${result.imported.strategies}`);
|
|
10454
|
+
console.log(` Skipped (duplicates): ${result.skipped}`);
|
|
9599
10455
|
log.newline();
|
|
9600
10456
|
} catch (error) {
|
|
9601
|
-
spinner.fail("
|
|
10457
|
+
spinner.fail("Import failed");
|
|
9602
10458
|
handleCliError(error);
|
|
9603
10459
|
process.exit(1);
|
|
9604
10460
|
}
|
|
9605
10461
|
}
|
|
10462
|
+
async function selectorsListCommand(options) {
|
|
10463
|
+
return selectorsPagesCommand({});
|
|
10464
|
+
}
|
|
10465
|
+
function formatTimeAgo(date) {
|
|
10466
|
+
const now = Date.now();
|
|
10467
|
+
const diff = now - date.getTime();
|
|
10468
|
+
const minutes = Math.floor(diff / 6e4);
|
|
10469
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
10470
|
+
const hours = Math.floor(minutes / 60);
|
|
10471
|
+
if (hours < 24) return `${hours}h ago`;
|
|
10472
|
+
const days = Math.floor(hours / 24);
|
|
10473
|
+
return `${days}d ago`;
|
|
10474
|
+
}
|
|
9606
10475
|
|
|
9607
10476
|
// src/commands/demo.ts
|
|
9608
10477
|
import chalk26 from "chalk";
|
|
@@ -9879,10 +10748,245 @@ function formatStatus2(status) {
|
|
|
9879
10748
|
}
|
|
9880
10749
|
}
|
|
9881
10750
|
|
|
10751
|
+
// src/commands/upgrade.ts
|
|
10752
|
+
import { execSync } from "child_process";
|
|
10753
|
+
import chalk28 from "chalk";
|
|
10754
|
+
|
|
10755
|
+
// package.json
|
|
10756
|
+
var package_default = {
|
|
10757
|
+
name: "@test-bro/cli",
|
|
10758
|
+
publishConfig: {
|
|
10759
|
+
access: "public"
|
|
10760
|
+
},
|
|
10761
|
+
version: "0.1.3",
|
|
10762
|
+
description: "TestBro CLI - AI-powered browser testing from your terminal",
|
|
10763
|
+
type: "module",
|
|
10764
|
+
bin: {
|
|
10765
|
+
testbro: "dist/index.js"
|
|
10766
|
+
},
|
|
10767
|
+
files: [
|
|
10768
|
+
"dist"
|
|
10769
|
+
],
|
|
10770
|
+
engines: {
|
|
10771
|
+
node: ">=18"
|
|
10772
|
+
},
|
|
10773
|
+
keywords: [
|
|
10774
|
+
"testing",
|
|
10775
|
+
"browser-testing",
|
|
10776
|
+
"playwright",
|
|
10777
|
+
"ai-testing",
|
|
10778
|
+
"test-automation",
|
|
10779
|
+
"e2e",
|
|
10780
|
+
"qa"
|
|
10781
|
+
],
|
|
10782
|
+
repository: {
|
|
10783
|
+
type: "git",
|
|
10784
|
+
url: "git+https://github.com/chernobelenkiy/test-bro.git",
|
|
10785
|
+
directory: "apps/cli"
|
|
10786
|
+
},
|
|
10787
|
+
license: "MIT",
|
|
10788
|
+
scripts: {
|
|
10789
|
+
build: "tsup",
|
|
10790
|
+
dev: "tsup --watch",
|
|
10791
|
+
typecheck: "tsc --noEmit",
|
|
10792
|
+
lint: "eslint src/",
|
|
10793
|
+
test: "vitest run",
|
|
10794
|
+
"test:watch": "vitest",
|
|
10795
|
+
"test:coverage": "vitest run --coverage"
|
|
10796
|
+
},
|
|
10797
|
+
dependencies: {
|
|
10798
|
+
commander: "^12.1.0",
|
|
10799
|
+
chalk: "^5.3.0",
|
|
10800
|
+
ora: "^8.1.1",
|
|
10801
|
+
dotenv: "^16.4.7",
|
|
10802
|
+
playwright: "^1.40.0",
|
|
10803
|
+
zod: "^3.24.1"
|
|
10804
|
+
},
|
|
10805
|
+
devDependencies: {
|
|
10806
|
+
"@testbro/core": "workspace:*",
|
|
10807
|
+
"@testbro/runner": "workspace:*",
|
|
10808
|
+
"@testbro/ai-agent": "workspace:*",
|
|
10809
|
+
"@types/node": "^22.10.2",
|
|
10810
|
+
"@vitest/coverage-v8": "^2.1.0",
|
|
10811
|
+
tsup: "^8.3.5",
|
|
10812
|
+
typescript: "^5.7.2",
|
|
10813
|
+
vitest: "^2.1.0"
|
|
10814
|
+
}
|
|
10815
|
+
};
|
|
10816
|
+
|
|
10817
|
+
// src/version.ts
|
|
10818
|
+
var CLI_VERSION = package_default.version;
|
|
10819
|
+
|
|
10820
|
+
// src/version-check.ts
|
|
10821
|
+
init_config();
|
|
10822
|
+
import * as fs10 from "fs";
|
|
10823
|
+
import * as path9 from "path";
|
|
10824
|
+
var CACHE_FILE_NAME = "version-check.json";
|
|
10825
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
10826
|
+
var NPM_REGISTRY_URL = "https://registry.npmjs.org/@test-bro/cli/latest";
|
|
10827
|
+
var FETCH_TIMEOUT_MS = 5e3;
|
|
10828
|
+
function getCachePath() {
|
|
10829
|
+
return path9.join(getConfigDir(), CACHE_FILE_NAME);
|
|
10830
|
+
}
|
|
10831
|
+
function readCache() {
|
|
10832
|
+
try {
|
|
10833
|
+
const cachePath = getCachePath();
|
|
10834
|
+
if (!fs10.existsSync(cachePath)) {
|
|
10835
|
+
return null;
|
|
10836
|
+
}
|
|
10837
|
+
const raw = fs10.readFileSync(cachePath, "utf-8");
|
|
10838
|
+
const data = JSON.parse(raw);
|
|
10839
|
+
const age = Date.now() - new Date(data.checkedAt).getTime();
|
|
10840
|
+
if (age > CACHE_TTL_MS) {
|
|
10841
|
+
return null;
|
|
10842
|
+
}
|
|
10843
|
+
return data;
|
|
10844
|
+
} catch {
|
|
10845
|
+
return null;
|
|
10846
|
+
}
|
|
10847
|
+
}
|
|
10848
|
+
function writeCache(latestVersion) {
|
|
10849
|
+
try {
|
|
10850
|
+
const configDir = getConfigDir();
|
|
10851
|
+
if (!fs10.existsSync(configDir)) {
|
|
10852
|
+
fs10.mkdirSync(configDir, { recursive: true, mode: 448 });
|
|
10853
|
+
}
|
|
10854
|
+
const data = {
|
|
10855
|
+
latestVersion,
|
|
10856
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
10857
|
+
};
|
|
10858
|
+
fs10.writeFileSync(getCachePath(), JSON.stringify(data, null, 2), {
|
|
10859
|
+
encoding: "utf-8",
|
|
10860
|
+
mode: 384
|
|
10861
|
+
});
|
|
10862
|
+
} catch {
|
|
10863
|
+
}
|
|
10864
|
+
}
|
|
10865
|
+
async function fetchLatestVersion() {
|
|
10866
|
+
try {
|
|
10867
|
+
const controller = new AbortController();
|
|
10868
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
10869
|
+
const response = await fetch(NPM_REGISTRY_URL, {
|
|
10870
|
+
signal: controller.signal,
|
|
10871
|
+
headers: { Accept: "application/json" }
|
|
10872
|
+
});
|
|
10873
|
+
clearTimeout(timeout);
|
|
10874
|
+
if (!response.ok) {
|
|
10875
|
+
return null;
|
|
10876
|
+
}
|
|
10877
|
+
const data = await response.json();
|
|
10878
|
+
return data.version ?? null;
|
|
10879
|
+
} catch {
|
|
10880
|
+
return null;
|
|
10881
|
+
}
|
|
10882
|
+
}
|
|
10883
|
+
function compareSemver(a, b) {
|
|
10884
|
+
const pa = a.split(".").map(Number);
|
|
10885
|
+
const pb = b.split(".").map(Number);
|
|
10886
|
+
const len = Math.max(pa.length, pb.length);
|
|
10887
|
+
for (let i = 0; i < len; i++) {
|
|
10888
|
+
const na = pa[i] ?? 0;
|
|
10889
|
+
const nb = pb[i] ?? 0;
|
|
10890
|
+
if (na < nb) return -1;
|
|
10891
|
+
if (na > nb) return 1;
|
|
10892
|
+
}
|
|
10893
|
+
return 0;
|
|
10894
|
+
}
|
|
10895
|
+
async function checkForUpdate(currentVersion) {
|
|
10896
|
+
const cached = readCache();
|
|
10897
|
+
if (cached) {
|
|
10898
|
+
if (compareSemver(currentVersion, cached.latestVersion) < 0) {
|
|
10899
|
+
return cached.latestVersion;
|
|
10900
|
+
}
|
|
10901
|
+
return null;
|
|
10902
|
+
}
|
|
10903
|
+
const latest = await fetchLatestVersion();
|
|
10904
|
+
if (!latest) {
|
|
10905
|
+
return null;
|
|
10906
|
+
}
|
|
10907
|
+
writeCache(latest);
|
|
10908
|
+
if (compareSemver(currentVersion, latest) < 0) {
|
|
10909
|
+
return latest;
|
|
10910
|
+
}
|
|
10911
|
+
return null;
|
|
10912
|
+
}
|
|
10913
|
+
function startVersionCheck(currentVersion) {
|
|
10914
|
+
const checkPromise = checkForUpdate(currentVersion).catch(() => null);
|
|
10915
|
+
return async () => {
|
|
10916
|
+
try {
|
|
10917
|
+
const latestVersion = await checkPromise;
|
|
10918
|
+
if (latestVersion) {
|
|
10919
|
+
const chalk30 = (await import("chalk")).default;
|
|
10920
|
+
console.log(
|
|
10921
|
+
chalk30.yellow(
|
|
10922
|
+
`
|
|
10923
|
+
Update available: ${currentVersion} \u2192 ${latestVersion}. Run \`testbro upgrade\` to update.`
|
|
10924
|
+
)
|
|
10925
|
+
);
|
|
10926
|
+
}
|
|
10927
|
+
} catch {
|
|
10928
|
+
}
|
|
10929
|
+
};
|
|
10930
|
+
}
|
|
10931
|
+
|
|
10932
|
+
// src/commands/upgrade.ts
|
|
10933
|
+
async function upgradeCommand() {
|
|
10934
|
+
console.log(chalk28.bold("TestBro CLI Upgrade"));
|
|
10935
|
+
console.log("");
|
|
10936
|
+
console.log(` Current version: ${chalk28.dim(CLI_VERSION)}`);
|
|
10937
|
+
console.log(chalk28.dim(" Checking npm registry..."));
|
|
10938
|
+
const latestVersion = await fetchLatestVersion();
|
|
10939
|
+
if (!latestVersion) {
|
|
10940
|
+
console.log(
|
|
10941
|
+
chalk28.red(
|
|
10942
|
+
" Failed to check npm registry. Please check your internet connection."
|
|
10943
|
+
)
|
|
10944
|
+
);
|
|
10945
|
+
process.exit(1);
|
|
10946
|
+
}
|
|
10947
|
+
console.log(` Latest version: ${chalk28.green(latestVersion)}`);
|
|
10948
|
+
console.log("");
|
|
10949
|
+
if (compareSemver(CLI_VERSION, latestVersion) >= 0) {
|
|
10950
|
+
console.log(chalk28.green(" You are already on the latest version."));
|
|
10951
|
+
return;
|
|
10952
|
+
}
|
|
10953
|
+
console.log(
|
|
10954
|
+
chalk28.yellow(
|
|
10955
|
+
` Upgrading ${CLI_VERSION} \u2192 ${latestVersion}...`
|
|
10956
|
+
)
|
|
10957
|
+
);
|
|
10958
|
+
console.log("");
|
|
10959
|
+
try {
|
|
10960
|
+
execSync("npm install -g @test-bro/cli@latest", {
|
|
10961
|
+
stdio: "inherit"
|
|
10962
|
+
});
|
|
10963
|
+
writeCache(latestVersion);
|
|
10964
|
+
console.log("");
|
|
10965
|
+
console.log(
|
|
10966
|
+
chalk28.green(
|
|
10967
|
+
` Successfully upgraded to @test-bro/cli@${latestVersion}`
|
|
10968
|
+
)
|
|
10969
|
+
);
|
|
10970
|
+
} catch {
|
|
10971
|
+
console.log("");
|
|
10972
|
+
console.log(
|
|
10973
|
+
chalk28.red(
|
|
10974
|
+
" Upgrade failed. You may need to run with sudo or fix npm permissions."
|
|
10975
|
+
)
|
|
10976
|
+
);
|
|
10977
|
+
console.log(
|
|
10978
|
+
chalk28.dim(
|
|
10979
|
+
" Try: sudo npm install -g @test-bro/cli@latest"
|
|
10980
|
+
)
|
|
10981
|
+
);
|
|
10982
|
+
process.exit(1);
|
|
10983
|
+
}
|
|
10984
|
+
}
|
|
10985
|
+
|
|
9882
10986
|
// src/index.ts
|
|
9883
10987
|
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:
|
|
10988
|
+
program.name("testbro").description("AI-powered browser testing from your terminal").version(CLI_VERSION);
|
|
10989
|
+
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
10990
|
try {
|
|
9887
10991
|
await loginCommand(email, { token: options.token, url: options.url });
|
|
9888
10992
|
} catch (error) {
|
|
@@ -9985,7 +11089,7 @@ program.command("generate").description("Generate test cases from a file or desc
|
|
|
9985
11089
|
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
11090
|
if (options.url && !validateUrl(options.url)) {
|
|
9987
11091
|
displayError("Invalid URL format", `Provided URL: ${options.url}`);
|
|
9988
|
-
console.log(
|
|
11092
|
+
console.log(chalk29.dim(' Example: testbro run --url https://example.com --description "..."'));
|
|
9989
11093
|
process.exit(1);
|
|
9990
11094
|
}
|
|
9991
11095
|
if (options.description) {
|
|
@@ -10118,8 +11222,8 @@ featuresCmd.command("assign").description("Assign a test case to a feature by sl
|
|
|
10118
11222
|
process.exit(1);
|
|
10119
11223
|
}
|
|
10120
11224
|
});
|
|
10121
|
-
var selectorsCmd = program.command("selectors").description("
|
|
10122
|
-
selectorsCmd.option("-
|
|
11225
|
+
var selectorsCmd = program.command("selectors").description("Browse and manage the selector library");
|
|
11226
|
+
selectorsCmd.option("-v, --verbose", "Show sections and elements").action(async (options) => {
|
|
10123
11227
|
try {
|
|
10124
11228
|
await selectorsListCommand(options);
|
|
10125
11229
|
} catch (error) {
|
|
@@ -10130,9 +11234,9 @@ selectorsCmd.option("-f, --feature <slug>", "Filter by feature slug").option("-s
|
|
|
10130
11234
|
process.exit(1);
|
|
10131
11235
|
}
|
|
10132
11236
|
});
|
|
10133
|
-
selectorsCmd.command("
|
|
11237
|
+
selectorsCmd.command("pages").description("List selector pages or show details for a specific page").option("-p, --page <urlPattern>", "Show detail tree for a specific page URL pattern").action(async (options) => {
|
|
10134
11238
|
try {
|
|
10135
|
-
await
|
|
11239
|
+
await selectorsPagesCommand(options);
|
|
10136
11240
|
} catch (error) {
|
|
10137
11241
|
if (error instanceof Error) {
|
|
10138
11242
|
displayError(error.message);
|
|
@@ -10141,7 +11245,95 @@ selectorsCmd.command("verify").description("Bulk re-verify selectors (refreshes
|
|
|
10141
11245
|
process.exit(1);
|
|
10142
11246
|
}
|
|
10143
11247
|
});
|
|
10144
|
-
selectorsCmd.command("
|
|
11248
|
+
selectorsCmd.command("elements").description("List elements with optional filters").option("-p, --page <urlPattern>", "Filter by page URL pattern").option("-s, --status <status>", "Filter by status (active, broken, deprecated)").option("-t, --tag <tag>", "Filter by tag").action(async (options) => {
|
|
11249
|
+
try {
|
|
11250
|
+
await selectorsElementsCommand(options);
|
|
11251
|
+
} catch (error) {
|
|
11252
|
+
if (error instanceof Error) {
|
|
11253
|
+
displayError(error.message);
|
|
11254
|
+
}
|
|
11255
|
+
printErrorHints(error);
|
|
11256
|
+
process.exit(1);
|
|
11257
|
+
}
|
|
11258
|
+
});
|
|
11259
|
+
selectorsCmd.command("find <description>").description("Fuzzy search for an element by description").action(async (description) => {
|
|
11260
|
+
try {
|
|
11261
|
+
await selectorsFindCommand(description);
|
|
11262
|
+
} catch (error) {
|
|
11263
|
+
if (error instanceof Error) {
|
|
11264
|
+
displayError(error.message);
|
|
11265
|
+
}
|
|
11266
|
+
printErrorHints(error);
|
|
11267
|
+
process.exit(1);
|
|
11268
|
+
}
|
|
11269
|
+
});
|
|
11270
|
+
selectorsCmd.command("add-page").description("Add a new page to the selector library").requiredOption("-n, --name <name>", "Page name").requiredOption("--url-pattern <pattern>", "URL pattern for the page").option("--tags <tags>", "Comma-separated tags").action(async (options) => {
|
|
11271
|
+
try {
|
|
11272
|
+
await selectorsAddPageCommand(options);
|
|
11273
|
+
} catch (error) {
|
|
11274
|
+
if (error instanceof Error) {
|
|
11275
|
+
displayError(error.message);
|
|
11276
|
+
}
|
|
11277
|
+
printErrorHints(error);
|
|
11278
|
+
process.exit(1);
|
|
11279
|
+
}
|
|
11280
|
+
});
|
|
11281
|
+
selectorsCmd.command("add-section").description("Add a section to an existing page").requiredOption("-p, --page <urlPattern>", "Parent page URL pattern").requiredOption("-n, --name <name>", "Section name").action(async (options) => {
|
|
11282
|
+
try {
|
|
11283
|
+
await selectorsAddSectionCommand(options);
|
|
11284
|
+
} catch (error) {
|
|
11285
|
+
if (error instanceof Error) {
|
|
11286
|
+
displayError(error.message);
|
|
11287
|
+
}
|
|
11288
|
+
printErrorHints(error);
|
|
11289
|
+
process.exit(1);
|
|
11290
|
+
}
|
|
11291
|
+
});
|
|
11292
|
+
selectorsCmd.command("add-element").description("Add an element to a page section").requiredOption("-p, --page <urlPattern>", "Page URL pattern").requiredOption("--section <name>", "Section name").requiredOption("-n, --name <name>", "Element name").option("-r, --role <role>", "Element role (e.g. textbox, button)").option("-s, --strategy <strategies...>", "Strategies (e.g. label=Email testid=email-input)").action(async (options) => {
|
|
11293
|
+
try {
|
|
11294
|
+
await selectorsAddElementCommand(options);
|
|
11295
|
+
} catch (error) {
|
|
11296
|
+
if (error instanceof Error) {
|
|
11297
|
+
displayError(error.message);
|
|
11298
|
+
}
|
|
11299
|
+
printErrorHints(error);
|
|
11300
|
+
process.exit(1);
|
|
11301
|
+
}
|
|
11302
|
+
});
|
|
11303
|
+
selectorsCmd.command("history").description("Show change history for an element").requiredOption("-e, --element <name>", "Element name").option("-p, --page <urlPattern>", "Page URL pattern to narrow search").action(async (options) => {
|
|
11304
|
+
try {
|
|
11305
|
+
await selectorsHistoryCommand(options);
|
|
11306
|
+
} catch (error) {
|
|
11307
|
+
if (error instanceof Error) {
|
|
11308
|
+
displayError(error.message);
|
|
11309
|
+
}
|
|
11310
|
+
printErrorHints(error);
|
|
11311
|
+
process.exit(1);
|
|
11312
|
+
}
|
|
11313
|
+
});
|
|
11314
|
+
selectorsCmd.command("rollback").description("Rollback an element to a previous version").requiredOption("-e, --element <name>", "Element name").requiredOption("--version <n>", "Version number to rollback to (1 = most recent)").option("-p, --page <urlPattern>", "Page URL pattern to narrow search").action(async (options) => {
|
|
11315
|
+
try {
|
|
11316
|
+
await selectorsRollbackCommand(options);
|
|
11317
|
+
} catch (error) {
|
|
11318
|
+
if (error instanceof Error) {
|
|
11319
|
+
displayError(error.message);
|
|
11320
|
+
}
|
|
11321
|
+
printErrorHints(error);
|
|
11322
|
+
process.exit(1);
|
|
11323
|
+
}
|
|
11324
|
+
});
|
|
11325
|
+
selectorsCmd.command("health").description("Show health summary of the selector library").action(async () => {
|
|
11326
|
+
try {
|
|
11327
|
+
await selectorsHealthCommand();
|
|
11328
|
+
} catch (error) {
|
|
11329
|
+
if (error instanceof Error) {
|
|
11330
|
+
displayError(error.message);
|
|
11331
|
+
}
|
|
11332
|
+
printErrorHints(error);
|
|
11333
|
+
process.exit(1);
|
|
11334
|
+
}
|
|
11335
|
+
});
|
|
11336
|
+
selectorsCmd.command("export").description("Export the selector library to a JSON file").requiredOption("-o, --output <path>", "Output file path").option("-p, --page <urlPattern>", "Export only a specific page").action(async (options) => {
|
|
10145
11337
|
try {
|
|
10146
11338
|
await selectorsExportCommand(options);
|
|
10147
11339
|
} catch (error) {
|
|
@@ -10152,6 +11344,17 @@ selectorsCmd.command("export").description("Export selectors as JSON or CSV").op
|
|
|
10152
11344
|
process.exit(1);
|
|
10153
11345
|
}
|
|
10154
11346
|
});
|
|
11347
|
+
selectorsCmd.command("import").description("Import a selector library from a JSON file").requiredOption("-i, --input <path>", "Input file path").option("--dry-run", "Preview the import without making changes").action(async (options) => {
|
|
11348
|
+
try {
|
|
11349
|
+
await selectorsImportCommand(options);
|
|
11350
|
+
} catch (error) {
|
|
11351
|
+
if (error instanceof Error) {
|
|
11352
|
+
displayError(error.message);
|
|
11353
|
+
}
|
|
11354
|
+
printErrorHints(error);
|
|
11355
|
+
process.exit(1);
|
|
11356
|
+
}
|
|
11357
|
+
});
|
|
10155
11358
|
var demoCmd = program.command("demo").description("Record and manage demo runs");
|
|
10156
11359
|
demoCmd.action(async () => {
|
|
10157
11360
|
try {
|
|
@@ -10231,6 +11434,17 @@ releasesCmd.command("unpublish").description("Unpublish a release note (revert t
|
|
|
10231
11434
|
process.exit(1);
|
|
10232
11435
|
}
|
|
10233
11436
|
});
|
|
11437
|
+
program.command("upgrade").description("Upgrade TestBro CLI to the latest version").action(async () => {
|
|
11438
|
+
try {
|
|
11439
|
+
await upgradeCommand();
|
|
11440
|
+
} catch (error) {
|
|
11441
|
+
if (error instanceof Error) {
|
|
11442
|
+
displayError(error.message);
|
|
11443
|
+
}
|
|
11444
|
+
printErrorHints(error);
|
|
11445
|
+
process.exit(1);
|
|
11446
|
+
}
|
|
11447
|
+
});
|
|
10234
11448
|
var configCmd = program.command("config").description("Show or update configuration");
|
|
10235
11449
|
function displayConfig() {
|
|
10236
11450
|
const { getConfigSummary: getConfigSummary2 } = (init_config(), __toCommonJS(config_exports));
|
|
@@ -10241,72 +11455,90 @@ function displayConfig() {
|
|
|
10241
11455
|
const userConfig = getConfigSummary2();
|
|
10242
11456
|
const projectConfigInfo = getProjectConfigSummary2();
|
|
10243
11457
|
console.log("");
|
|
10244
|
-
console.log(
|
|
11458
|
+
console.log(chalk29.bold("TestBro Configuration"));
|
|
10245
11459
|
console.log("");
|
|
10246
|
-
console.log(
|
|
10247
|
-
console.log(` ${
|
|
10248
|
-
console.log(` ${
|
|
11460
|
+
console.log(chalk29.underline("User Config") + chalk29.dim(` (~/.testbro/config.json)`));
|
|
11461
|
+
console.log(` ${chalk29.dim("Path:")} ${userConfig.configPath}`);
|
|
11462
|
+
console.log(` ${chalk29.dim("API URL:")} ${userConfig.apiUrl}`);
|
|
10249
11463
|
console.log(
|
|
10250
|
-
` ${
|
|
11464
|
+
` ${chalk29.dim("Authenticated:")} ${userConfig.authenticated ? chalk29.green("Yes") : chalk29.yellow("No")}`
|
|
10251
11465
|
);
|
|
10252
11466
|
if (userConfig.activeProjectId) {
|
|
10253
|
-
console.log(` ${
|
|
11467
|
+
console.log(` ${chalk29.dim("Active Project:")} ${userConfig.activeProjectId}`);
|
|
10254
11468
|
}
|
|
10255
11469
|
console.log("");
|
|
10256
|
-
console.log(
|
|
11470
|
+
console.log(chalk29.underline("Project Config") + chalk29.dim(` (test-bro.config.json)`));
|
|
10257
11471
|
if (projectConfigInfo.projectConfigPath) {
|
|
10258
|
-
console.log(` ${
|
|
11472
|
+
console.log(` ${chalk29.dim("Path:")} ${projectConfigInfo.projectConfigPath}`);
|
|
10259
11473
|
if (projectConfigInfo.error) {
|
|
10260
|
-
console.log(` ${
|
|
11474
|
+
console.log(` ${chalk29.red("Error:")} ${projectConfigInfo.error}`);
|
|
10261
11475
|
} else if (projectConfigInfo.projectConfig) {
|
|
10262
11476
|
const pc = projectConfigInfo.projectConfig;
|
|
10263
11477
|
if (pc.baseUrl) {
|
|
10264
|
-
console.log(` ${
|
|
11478
|
+
console.log(` ${chalk29.dim("Base URL:")} ${pc.baseUrl}`);
|
|
10265
11479
|
}
|
|
10266
11480
|
if (pc.environment) {
|
|
10267
|
-
console.log(` ${
|
|
11481
|
+
console.log(` ${chalk29.dim("Environment:")} ${pc.environment}`);
|
|
10268
11482
|
}
|
|
10269
11483
|
if (pc.mode) {
|
|
10270
|
-
console.log(` ${
|
|
11484
|
+
console.log(` ${chalk29.dim("Mode:")} ${pc.mode}`);
|
|
10271
11485
|
}
|
|
10272
11486
|
if (pc.timeout) {
|
|
10273
|
-
console.log(` ${
|
|
11487
|
+
console.log(` ${chalk29.dim("Timeout:")} ${pc.timeout}ms`);
|
|
10274
11488
|
}
|
|
10275
11489
|
if (pc.headed !== void 0) {
|
|
10276
|
-
console.log(` ${
|
|
11490
|
+
console.log(` ${chalk29.dim("Headed:")} ${pc.headed}`);
|
|
10277
11491
|
}
|
|
10278
11492
|
if (pc.projectId) {
|
|
10279
|
-
console.log(` ${
|
|
11493
|
+
console.log(` ${chalk29.dim("Project ID:")} ${pc.projectId}`);
|
|
10280
11494
|
}
|
|
10281
11495
|
const credNames = getCredentialNames2(pc);
|
|
10282
11496
|
if (credNames.length > 0) {
|
|
10283
|
-
console.log(` ${
|
|
11497
|
+
console.log(` ${chalk29.dim("Credentials:")} ${credNames.join(", ")}`);
|
|
10284
11498
|
}
|
|
10285
11499
|
if (pc.deduplication) {
|
|
10286
11500
|
const dedupEnabled = pc.deduplication.enabled !== false;
|
|
10287
11501
|
const autoMerge = pc.deduplication.autoMerge === true;
|
|
10288
|
-
console.log(` ${
|
|
11502
|
+
console.log(` ${chalk29.dim("Deduplication:")} ${dedupEnabled ? chalk29.green("enabled") : chalk29.yellow("disabled")}${autoMerge ? chalk29.dim(" (auto-merge)") : ""}`);
|
|
11503
|
+
}
|
|
11504
|
+
if (pc.environments && Object.keys(pc.environments).length > 0) {
|
|
11505
|
+
const envNames = Object.keys(pc.environments);
|
|
11506
|
+
const activeEnv = pc.environment || "local";
|
|
11507
|
+
console.log(` ${chalk29.dim("Environments:")} ${envNames.map((name) => name === activeEnv ? chalk29.green(name + " *") : name).join(", ")}`);
|
|
11508
|
+
const activeEntry = pc.environments[activeEnv];
|
|
11509
|
+
if (activeEntry) {
|
|
11510
|
+
console.log(` ${chalk29.dim("Active env URL:")} ${activeEntry.baseUrl}`);
|
|
11511
|
+
if (activeEntry.apiUrl) {
|
|
11512
|
+
console.log(` ${chalk29.dim("Active env API:")} ${activeEntry.apiUrl}`);
|
|
11513
|
+
}
|
|
11514
|
+
if (activeEntry.credentials) {
|
|
11515
|
+
console.log(` ${chalk29.dim("Active env credentials:")} ${Object.keys(activeEntry.credentials).join(", ")}`);
|
|
11516
|
+
}
|
|
11517
|
+
if (activeEntry.auth) {
|
|
11518
|
+
console.log(` ${chalk29.dim("Active env auth:")} ${chalk29.green("configured")} ${chalk29.dim(`(${activeEntry.auth.loginUrl})`)}`);
|
|
11519
|
+
}
|
|
11520
|
+
}
|
|
10289
11521
|
}
|
|
10290
11522
|
}
|
|
10291
11523
|
} else {
|
|
10292
|
-
console.log(` ${
|
|
11524
|
+
console.log(` ${chalk29.dim("Not found")}`);
|
|
10293
11525
|
}
|
|
10294
11526
|
console.log("");
|
|
10295
|
-
console.log(
|
|
11527
|
+
console.log(chalk29.underline("Authentication"));
|
|
10296
11528
|
if (projectConfigInfo.token) {
|
|
10297
11529
|
const maskedToken = projectConfigInfo.token.length > 8 ? `${projectConfigInfo.token.slice(0, 4)}...${projectConfigInfo.token.slice(-4)}` : "****";
|
|
10298
|
-
const sourceLabel = projectConfigInfo.tokenSource === "env" ?
|
|
10299
|
-
console.log(` ${
|
|
11530
|
+
const sourceLabel = projectConfigInfo.tokenSource === "env" ? chalk29.cyan("(from TEST_BRO_TOKEN env)") : chalk29.dim("(from user config)");
|
|
11531
|
+
console.log(` ${chalk29.dim("Token:")} ${maskedToken} ${sourceLabel}`);
|
|
10300
11532
|
} else {
|
|
10301
|
-
console.log(` ${
|
|
10302
|
-
console.log(
|
|
10303
|
-
console.log(
|
|
11533
|
+
console.log(` ${chalk29.yellow("No token configured")}`);
|
|
11534
|
+
console.log(chalk29.dim(" Set via: testbro init (or testbro login --token <token>)"));
|
|
11535
|
+
console.log(chalk29.dim(" Or set TEST_BRO_TOKEN environment variable"));
|
|
10304
11536
|
}
|
|
10305
11537
|
if (projectConfigInfo.openRouterApiKey) {
|
|
10306
11538
|
const maskedKey = projectConfigInfo.openRouterApiKey.length > 8 ? `${projectConfigInfo.openRouterApiKey.slice(0, 6)}...${projectConfigInfo.openRouterApiKey.slice(-4)}` : "****";
|
|
10307
|
-
console.log(` ${
|
|
11539
|
+
console.log(` ${chalk29.dim("OpenRouter API Key:")} ${maskedKey} ${chalk29.cyan("(from OPENROUTER_API_KEY env)")}`);
|
|
10308
11540
|
} else {
|
|
10309
|
-
console.log(` ${
|
|
11541
|
+
console.log(` ${chalk29.dim("OpenRouter API Key:")} ${chalk29.yellow("Not set")} ${chalk29.dim("(required for AI mode)")}`);
|
|
10310
11542
|
}
|
|
10311
11543
|
console.log("");
|
|
10312
11544
|
}
|
|
@@ -10320,11 +11552,11 @@ configCmd.command("init").description("Create a test-bro.config.json template in
|
|
|
10320
11552
|
try {
|
|
10321
11553
|
const { createConfigFile: createConfigFile2 } = (init_project_config(), __toCommonJS(project_config_exports));
|
|
10322
11554
|
const configPath = createConfigFile2();
|
|
10323
|
-
console.log(
|
|
11555
|
+
console.log(chalk29.green(`Created ${configPath}`));
|
|
10324
11556
|
console.log("");
|
|
10325
|
-
console.log(
|
|
10326
|
-
console.log(
|
|
10327
|
-
console.log(
|
|
11557
|
+
console.log(chalk29.dim("Edit this file to configure your project settings."));
|
|
11558
|
+
console.log(chalk29.dim("Note: Never store tokens in this file. Use .env or testbro login."));
|
|
11559
|
+
console.log(chalk29.dim("Tip: Use 'testbro init' for interactive setup with authentication."));
|
|
10328
11560
|
} catch (error) {
|
|
10329
11561
|
if (error instanceof Error) {
|
|
10330
11562
|
displayError(error.message);
|
|
@@ -10338,7 +11570,11 @@ configCmd.option("--api-url <url>", "Set the API URL in user config").hook("preA
|
|
|
10338
11570
|
if (options.apiUrl) {
|
|
10339
11571
|
const { setApiUrl: setApiUrl2 } = (init_config(), __toCommonJS(config_exports));
|
|
10340
11572
|
setApiUrl2(options.apiUrl);
|
|
10341
|
-
console.log(
|
|
11573
|
+
console.log(chalk29.green(`API URL updated to: ${options.apiUrl}`));
|
|
10342
11574
|
}
|
|
10343
11575
|
});
|
|
11576
|
+
var showVersionNotice = startVersionCheck(CLI_VERSION);
|
|
11577
|
+
program.hook("postAction", async () => {
|
|
11578
|
+
await showVersionNotice();
|
|
11579
|
+
});
|
|
10344
11580
|
program.parse();
|