@test-bro/cli 0.1.2 → 0.1.4
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/dist/index.js +1244 -265
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -39,6 +39,7 @@ __export(config_exports, {
|
|
|
39
39
|
getConfigSummary: () => getConfigSummary,
|
|
40
40
|
getToken: () => getToken,
|
|
41
41
|
isAuthenticated: () => isAuthenticated,
|
|
42
|
+
overrideApiUrl: () => overrideApiUrl,
|
|
42
43
|
readConfig: () => readConfig,
|
|
43
44
|
removeActiveProject: () => removeActiveProject,
|
|
44
45
|
removeApiKey: () => removeApiKey,
|
|
@@ -153,11 +154,14 @@ function removeActiveProject() {
|
|
|
153
154
|
writeConfigRaw(config);
|
|
154
155
|
}
|
|
155
156
|
function getApiUrl() {
|
|
156
|
-
return readConfig().apiUrl;
|
|
157
|
+
return apiUrlOverride ?? readConfig().apiUrl;
|
|
157
158
|
}
|
|
158
159
|
function setApiUrl(apiUrl) {
|
|
159
160
|
writeConfig({ apiUrl });
|
|
160
161
|
}
|
|
162
|
+
function overrideApiUrl(apiUrl) {
|
|
163
|
+
apiUrlOverride = apiUrl;
|
|
164
|
+
}
|
|
161
165
|
function isAuthenticated() {
|
|
162
166
|
const token = getToken();
|
|
163
167
|
return token !== void 0 && token.length > 0;
|
|
@@ -175,7 +179,7 @@ function getConfigSummary() {
|
|
|
175
179
|
configPath: getConfigPath()
|
|
176
180
|
};
|
|
177
181
|
}
|
|
178
|
-
var DEFAULT_CONFIG;
|
|
182
|
+
var DEFAULT_CONFIG, apiUrlOverride;
|
|
179
183
|
var init_config = __esm({
|
|
180
184
|
"src/config.ts"() {
|
|
181
185
|
"use strict";
|
|
@@ -197,6 +201,7 @@ __export(project_config_exports, {
|
|
|
197
201
|
getProjectToken: () => getProjectToken,
|
|
198
202
|
loadProjectConfig: () => loadProjectConfig,
|
|
199
203
|
mergeWithCliOptions: () => mergeWithCliOptions,
|
|
204
|
+
resolveActiveEnvironment: () => resolveActiveEnvironment,
|
|
200
205
|
substituteCredentials: () => substituteCredentials
|
|
201
206
|
});
|
|
202
207
|
import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -257,6 +262,13 @@ function getCredentialNames(config) {
|
|
|
257
262
|
}
|
|
258
263
|
return Object.keys(config.credentials);
|
|
259
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
|
+
}
|
|
260
272
|
function substituteCredentials(value, config) {
|
|
261
273
|
if (!config?.credentials || !value.includes("{{credentials.")) {
|
|
262
274
|
return value;
|
|
@@ -364,7 +376,7 @@ function getProjectConfigSummary() {
|
|
|
364
376
|
openRouterApiKey: getOpenRouterApiKey()
|
|
365
377
|
};
|
|
366
378
|
}
|
|
367
|
-
var credentialSchema,
|
|
379
|
+
var credentialSchema, deduplicationConfigSchema, authStepSchema, authConfigSchema, environmentEntrySchema, projectConfigSchema, CONFIG_FILE_NAMES, CONFIG_TEMPLATE;
|
|
368
380
|
var init_project_config = __esm({
|
|
369
381
|
"src/project-config.ts"() {
|
|
370
382
|
"use strict";
|
|
@@ -372,11 +384,6 @@ var init_project_config = __esm({
|
|
|
372
384
|
email: z.string().email(),
|
|
373
385
|
password: z.string().min(1)
|
|
374
386
|
});
|
|
375
|
-
localEnvironmentSchema = z.object({
|
|
376
|
-
name: z.string(),
|
|
377
|
-
baseUrl: z.string().url(),
|
|
378
|
-
variables: z.record(z.string()).optional()
|
|
379
|
-
});
|
|
380
387
|
deduplicationConfigSchema = z.object({
|
|
381
388
|
enabled: z.boolean().optional(),
|
|
382
389
|
autoMerge: z.boolean().optional()
|
|
@@ -395,6 +402,14 @@ var init_project_config = __esm({
|
|
|
395
402
|
maxAge: z.number().optional(),
|
|
396
403
|
waitForUrl: z.string().optional()
|
|
397
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()
|
|
412
|
+
});
|
|
398
413
|
projectConfigSchema = z.object({
|
|
399
414
|
$schema: z.string().optional(),
|
|
400
415
|
baseUrl: z.string().url().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
|
|
@@ -729,15 +755,6 @@ async function compareTestCases(projectId, testCases) {
|
|
|
729
755
|
}
|
|
730
756
|
);
|
|
731
757
|
}
|
|
732
|
-
async function saveLearnedSelectors(projectId, testCaseId, learnedSelectors) {
|
|
733
|
-
return apiRequest(
|
|
734
|
-
`/api/projects/${projectId}/test-cases/${testCaseId}/learn-selectors`,
|
|
735
|
-
{
|
|
736
|
-
method: "POST",
|
|
737
|
-
body: JSON.stringify({ learnedSelectors })
|
|
738
|
-
}
|
|
739
|
-
);
|
|
740
|
-
}
|
|
741
758
|
async function applyCorrections(projectId, testCaseId, corrections) {
|
|
742
759
|
return apiRequest(
|
|
743
760
|
`/api/projects/${projectId}/test-cases/${testCaseId}/apply-corrections`,
|
|
@@ -888,63 +905,102 @@ async function assignTestCaseToFeature(projectId, featureSlug, testCaseId) {
|
|
|
888
905
|
}
|
|
889
906
|
);
|
|
890
907
|
}
|
|
891
|
-
async function
|
|
908
|
+
async function listSelectorPages(projectId, options) {
|
|
892
909
|
const params = new URLSearchParams();
|
|
893
|
-
if (options?.
|
|
894
|
-
|
|
895
|
-
const feature = features.find((f) => f.slug === options.featureSlug);
|
|
896
|
-
if (feature) {
|
|
897
|
-
params.set("featureId", feature.id);
|
|
898
|
-
} else {
|
|
899
|
-
return { selectors: [], total: 0, page: 1, limit: 50 };
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
if (options?.status) {
|
|
903
|
-
params.set("status", options.status);
|
|
904
|
-
}
|
|
910
|
+
if (options?.tag) params.set("tag", options.tag);
|
|
911
|
+
if (options?.search) params.set("search", options.search);
|
|
905
912
|
const query = params.toString();
|
|
906
913
|
return apiRequest(
|
|
907
|
-
`/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
|
+
}
|
|
908
924
|
);
|
|
909
925
|
}
|
|
910
|
-
async function
|
|
926
|
+
async function learnSelectors(projectId, pageUrl, learnedElements) {
|
|
911
927
|
return apiRequest(
|
|
912
|
-
`/api/projects/${projectId}/
|
|
928
|
+
`/api/projects/${projectId}/selector-learn`,
|
|
913
929
|
{
|
|
914
930
|
method: "POST",
|
|
915
|
-
body: JSON.stringify(
|
|
931
|
+
body: JSON.stringify({ pageUrl, learnedElements })
|
|
916
932
|
}
|
|
917
933
|
);
|
|
918
934
|
}
|
|
919
|
-
async function
|
|
935
|
+
async function listSelectorElements(projectId, filters) {
|
|
920
936
|
const params = new URLSearchParams();
|
|
921
|
-
params.set("
|
|
922
|
-
if (
|
|
923
|
-
if (
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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)
|
|
941
1002
|
}
|
|
942
|
-
return await response.text();
|
|
943
|
-
}
|
|
944
|
-
const data = await apiRequest(
|
|
945
|
-
`/api/projects/${projectId}/selectors/export?${params.toString()}`
|
|
946
1003
|
);
|
|
947
|
-
return JSON.stringify(data.selectors, null, 2);
|
|
948
1004
|
}
|
|
949
1005
|
async function createDemoRun(projectId, data) {
|
|
950
1006
|
return apiRequest(
|
|
@@ -1728,29 +1784,42 @@ async function resolveEnvironment(name, projectId, options = {}) {
|
|
|
1728
1784
|
if (preferCloud) {
|
|
1729
1785
|
return await resolveEnvironmentFromCloud(name, projectId) ?? resolveEnvironmentFromLocal(name);
|
|
1730
1786
|
}
|
|
1731
|
-
return resolveEnvironmentFromLocal(name) ?? (
|
|
1787
|
+
return resolveEnvironmentFromLocal(name) ?? (isAuthenticated() ? resolveEnvironmentFromCloud(name, projectId) : null);
|
|
1732
1788
|
}
|
|
1733
1789
|
async function resolveCredential(name, projectId, options = {}) {
|
|
1734
1790
|
const { preferCloud = false } = options;
|
|
1735
1791
|
if (preferCloud) {
|
|
1736
1792
|
return await resolveCredentialFromCloud(name, projectId) ?? resolveCredentialFromLocal(name);
|
|
1737
1793
|
}
|
|
1738
|
-
return resolveCredentialFromLocal(name) ?? (
|
|
1794
|
+
return resolveCredentialFromLocal(name) ?? (isAuthenticated() ? resolveCredentialFromCloud(name, projectId) : null);
|
|
1739
1795
|
}
|
|
1740
1796
|
function resolveEnvironmentFromLocal(name) {
|
|
1741
1797
|
const { config } = loadProjectConfig();
|
|
1742
1798
|
if (!config?.environments) return null;
|
|
1743
|
-
const
|
|
1744
|
-
(
|
|
1799
|
+
const key = Object.keys(config.environments).find(
|
|
1800
|
+
(k) => k.toLowerCase() === name.toLowerCase()
|
|
1745
1801
|
);
|
|
1746
|
-
if (!
|
|
1802
|
+
if (!key) return null;
|
|
1803
|
+
const entry = config.environments[key];
|
|
1747
1804
|
return {
|
|
1748
|
-
name:
|
|
1749
|
-
baseUrl:
|
|
1750
|
-
variables:
|
|
1805
|
+
name: key,
|
|
1806
|
+
baseUrl: entry.baseUrl,
|
|
1807
|
+
variables: entry.variables ?? {},
|
|
1751
1808
|
source: "local"
|
|
1752
1809
|
};
|
|
1753
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
|
+
}
|
|
1754
1823
|
async function resolveEnvironmentFromCloud(name, projectId) {
|
|
1755
1824
|
try {
|
|
1756
1825
|
const environments = await getProjectEnvironments(projectId);
|
|
@@ -1809,10 +1878,6 @@ async function resolveCredentialFromCloud(name, projectId) {
|
|
|
1809
1878
|
throw error;
|
|
1810
1879
|
}
|
|
1811
1880
|
}
|
|
1812
|
-
function isAuthenticated2() {
|
|
1813
|
-
const token = getToken();
|
|
1814
|
-
return token !== void 0 && token.length > 0;
|
|
1815
|
-
}
|
|
1816
1881
|
|
|
1817
1882
|
// src/commands/auth/auth-executor.ts
|
|
1818
1883
|
import * as fs3 from "fs";
|
|
@@ -2780,8 +2845,8 @@ function stripLoginSteps(steps) {
|
|
|
2780
2845
|
}
|
|
2781
2846
|
return loginEndIndex > 0 ? steps.slice(loginEndIndex) : steps;
|
|
2782
2847
|
}
|
|
2783
|
-
function convertToTestPlan(projectTestCases, runId, projectConfig) {
|
|
2784
|
-
const
|
|
2848
|
+
function convertToTestPlan(projectTestCases, runId, projectConfig, authEnabled) {
|
|
2849
|
+
const shouldStripLogin = authEnabled ?? !!projectConfig?.auth;
|
|
2785
2850
|
const testCases = projectTestCases.map((ptc) => {
|
|
2786
2851
|
const steps = ptc.steps.map((step, stepIndex) => {
|
|
2787
2852
|
let target;
|
|
@@ -2819,7 +2884,7 @@ function convertToTestPlan(projectTestCases, runId, projectConfig) {
|
|
|
2819
2884
|
assertion
|
|
2820
2885
|
};
|
|
2821
2886
|
});
|
|
2822
|
-
const finalSteps =
|
|
2887
|
+
const finalSteps = shouldStripLogin ? stripLoginSteps(steps) : steps;
|
|
2823
2888
|
return {
|
|
2824
2889
|
id: ptc.id,
|
|
2825
2890
|
name: ptc.name,
|
|
@@ -2846,8 +2911,11 @@ async function buildTestPlan(options) {
|
|
|
2846
2911
|
generatedFromFile,
|
|
2847
2912
|
fileGeneratedTestCases,
|
|
2848
2913
|
projectConfig,
|
|
2849
|
-
isPretty
|
|
2914
|
+
isPretty,
|
|
2915
|
+
environment,
|
|
2916
|
+
authEnabled
|
|
2850
2917
|
} = options;
|
|
2918
|
+
const envName = environment || (remote ? "staging" : "local");
|
|
2851
2919
|
const useProjectTestCases = !description && !generatedFromFile && projectId;
|
|
2852
2920
|
if (generatedFromFile && fileGeneratedTestCases) {
|
|
2853
2921
|
return buildFromGeneratedTestCases(
|
|
@@ -2855,9 +2923,10 @@ async function buildTestPlan(options) {
|
|
|
2855
2923
|
file,
|
|
2856
2924
|
projectId,
|
|
2857
2925
|
url,
|
|
2858
|
-
|
|
2926
|
+
envName,
|
|
2859
2927
|
projectConfig,
|
|
2860
|
-
isPretty
|
|
2928
|
+
isPretty,
|
|
2929
|
+
authEnabled
|
|
2861
2930
|
);
|
|
2862
2931
|
}
|
|
2863
2932
|
if (useProjectTestCases) {
|
|
@@ -2865,34 +2934,35 @@ async function buildTestPlan(options) {
|
|
|
2865
2934
|
projectId,
|
|
2866
2935
|
testCaseId,
|
|
2867
2936
|
url,
|
|
2868
|
-
|
|
2937
|
+
envName,
|
|
2869
2938
|
projectConfig,
|
|
2870
|
-
isPretty
|
|
2939
|
+
isPretty,
|
|
2940
|
+
authEnabled
|
|
2871
2941
|
);
|
|
2872
2942
|
}
|
|
2873
|
-
return buildFromDescription(description, url,
|
|
2943
|
+
return buildFromDescription(description, url, envName, projectId, isPretty);
|
|
2874
2944
|
}
|
|
2875
|
-
async function buildFromGeneratedTestCases(testCases, file, projectId, url,
|
|
2945
|
+
async function buildFromGeneratedTestCases(testCases, file, projectId, url, envName, projectConfig, isPretty, authEnabled) {
|
|
2876
2946
|
const { project: projectInfo } = await getProject(projectId);
|
|
2877
2947
|
const createSpinnerInstance = isPretty ? createSpinner("Creating test run...").start() : null;
|
|
2878
2948
|
const featureDesc = `Generated from: ${path4.basename(file)}`;
|
|
2879
2949
|
const createResponse = await createRun(
|
|
2880
2950
|
featureDesc,
|
|
2881
2951
|
url,
|
|
2882
|
-
|
|
2952
|
+
envName,
|
|
2883
2953
|
projectId
|
|
2884
2954
|
);
|
|
2885
2955
|
const runId = createResponse.run.id;
|
|
2886
2956
|
createSpinnerInstance?.succeed(
|
|
2887
2957
|
`Test run created ${chalk9.dim(`(ID: ${runId.slice(0, 8)}...)`)}`
|
|
2888
2958
|
);
|
|
2889
|
-
const testPlan = convertToTestPlan(testCases, runId, projectConfig);
|
|
2959
|
+
const testPlan = convertToTestPlan(testCases, runId, projectConfig, authEnabled);
|
|
2890
2960
|
if (isPretty) {
|
|
2891
2961
|
log.info(`Running ${testCases.length} generated test case${testCases.length === 1 ? "" : "s"}...`);
|
|
2892
2962
|
}
|
|
2893
2963
|
return { runId, testPlan };
|
|
2894
2964
|
}
|
|
2895
|
-
async function buildFromProjectTestCases(projectId, testCaseId, url,
|
|
2965
|
+
async function buildFromProjectTestCases(projectId, testCaseId, url, envName, projectConfig, isPretty, authEnabled) {
|
|
2896
2966
|
const projectSpinner = isPretty ? createSpinner("Fetching project test cases...").start() : null;
|
|
2897
2967
|
try {
|
|
2898
2968
|
const { project: projectInfo } = await getProject(projectId);
|
|
@@ -2918,14 +2988,14 @@ async function buildFromProjectTestCases(projectId, testCaseId, url, remote, pro
|
|
|
2918
2988
|
const createResponse = await createRun(
|
|
2919
2989
|
featureDesc,
|
|
2920
2990
|
url,
|
|
2921
|
-
|
|
2991
|
+
envName,
|
|
2922
2992
|
projectId
|
|
2923
2993
|
);
|
|
2924
2994
|
const runId = createResponse.run.id;
|
|
2925
2995
|
createSpinnerInstance?.succeed(
|
|
2926
2996
|
`Test run created ${chalk9.dim(`(ID: ${runId.slice(0, 8)}...)`)}`
|
|
2927
2997
|
);
|
|
2928
|
-
const testPlan = convertToTestPlan(projectTestCases, runId, projectConfig);
|
|
2998
|
+
const testPlan = convertToTestPlan(projectTestCases, runId, projectConfig, authEnabled);
|
|
2929
2999
|
return { runId, testPlan };
|
|
2930
3000
|
} catch (error) {
|
|
2931
3001
|
projectSpinner?.fail("Failed to fetch project");
|
|
@@ -2942,12 +3012,12 @@ async function buildFromProjectTestCases(projectId, testCaseId, url, remote, pro
|
|
|
2942
3012
|
process.exit(1);
|
|
2943
3013
|
}
|
|
2944
3014
|
}
|
|
2945
|
-
async function buildFromDescription(description, url,
|
|
3015
|
+
async function buildFromDescription(description, url, envName, projectId, isPretty) {
|
|
2946
3016
|
const createSpinnerInstance = isPretty ? createSpinner("Creating test run...").start() : null;
|
|
2947
3017
|
const createResponse = await createRun(
|
|
2948
3018
|
description,
|
|
2949
3019
|
url,
|
|
2950
|
-
|
|
3020
|
+
envName,
|
|
2951
3021
|
projectId
|
|
2952
3022
|
);
|
|
2953
3023
|
const runId = createResponse.run.id;
|
|
@@ -6107,6 +6177,7 @@ var HybridTestExecutor = class {
|
|
|
6107
6177
|
this.videoDir = null;
|
|
6108
6178
|
this.traceDir = null;
|
|
6109
6179
|
this.traceFilePath = null;
|
|
6180
|
+
this.libraryIndex = null;
|
|
6110
6181
|
this.options = {
|
|
6111
6182
|
baseUrl: options.baseUrl ?? "",
|
|
6112
6183
|
headed: options.headed ?? false,
|
|
@@ -6118,6 +6189,9 @@ var HybridTestExecutor = class {
|
|
|
6118
6189
|
storageStatePath: options.storageStatePath ?? "",
|
|
6119
6190
|
actionModel: options.actionModel ?? DEFAULT_ACTION_MODEL2,
|
|
6120
6191
|
assertionModel: options.assertionModel ?? DEFAULT_ASSERTION_MODEL2,
|
|
6192
|
+
apiBaseUrl: options.apiBaseUrl ?? "",
|
|
6193
|
+
apiToken: options.apiToken ?? "",
|
|
6194
|
+
projectId: options.projectId ?? "",
|
|
6121
6195
|
visionClientOptions: options.visionClientOptions,
|
|
6122
6196
|
onStepComplete: options.onStepComplete,
|
|
6123
6197
|
onTestCaseStart: options.onTestCaseStart,
|
|
@@ -6331,16 +6405,17 @@ var HybridTestExecutor = class {
|
|
|
6331
6405
|
* Execute a single step with hybrid logic
|
|
6332
6406
|
*/
|
|
6333
6407
|
async executeStep(step) {
|
|
6334
|
-
const
|
|
6408
|
+
const enrichedStep = this.enrichStepFromLibrary(step);
|
|
6409
|
+
const hasSelectors = (enrichedStep.target?.suggestedStrategies?.length ?? 0) > 0;
|
|
6335
6410
|
const hasAI = this.visionClient && this.aiExecutor;
|
|
6336
|
-
if (
|
|
6337
|
-
return this.executeNavigate(
|
|
6411
|
+
if (enrichedStep.action === "navigate" && enrichedStep.value) {
|
|
6412
|
+
return this.executeNavigate(enrichedStep);
|
|
6338
6413
|
}
|
|
6339
6414
|
if (hasSelectors) {
|
|
6340
|
-
const selectorResult = await this.trySelector(
|
|
6415
|
+
const selectorResult = await this.trySelector(enrichedStep);
|
|
6341
6416
|
if (selectorResult.success) {
|
|
6342
6417
|
const result3 = {
|
|
6343
|
-
stepId:
|
|
6418
|
+
stepId: enrichedStep.id,
|
|
6344
6419
|
status: "passed",
|
|
6345
6420
|
method: "selector",
|
|
6346
6421
|
duration: selectorResult.duration,
|
|
@@ -6350,22 +6425,12 @@ var HybridTestExecutor = class {
|
|
|
6350
6425
|
return result3;
|
|
6351
6426
|
}
|
|
6352
6427
|
if (hasAI) {
|
|
6353
|
-
this.options.onFallback?.(
|
|
6354
|
-
const aiLog = await this.executeWithAI(
|
|
6428
|
+
this.options.onFallback?.(enrichedStep.id, selectorResult.error || "Selector failed");
|
|
6429
|
+
const aiLog = await this.executeWithAI(enrichedStep);
|
|
6355
6430
|
if (aiLog?.success) {
|
|
6356
|
-
|
|
6357
|
-
if (this.options.learnFromFallback && aiLog.element) {
|
|
6358
|
-
const strategies = generateSelectors(aiLog.element);
|
|
6359
|
-
if (strategies.length > 0) {
|
|
6360
|
-
learnedSelector = {
|
|
6361
|
-
stepId: step.id,
|
|
6362
|
-
elementDescription: aiLog.action.elementDescription || "",
|
|
6363
|
-
strategies
|
|
6364
|
-
};
|
|
6365
|
-
}
|
|
6366
|
-
}
|
|
6431
|
+
const learnedSelector = this.learnFromAIResult(enrichedStep.id, aiLog);
|
|
6367
6432
|
const result4 = {
|
|
6368
|
-
stepId:
|
|
6433
|
+
stepId: enrichedStep.id,
|
|
6369
6434
|
status: "passed",
|
|
6370
6435
|
method: "fallback",
|
|
6371
6436
|
duration: (selectorResult.duration || 0) + aiLog.duration,
|
|
@@ -6378,7 +6443,7 @@ var HybridTestExecutor = class {
|
|
|
6378
6443
|
return result4;
|
|
6379
6444
|
}
|
|
6380
6445
|
const result3 = {
|
|
6381
|
-
stepId:
|
|
6446
|
+
stepId: enrichedStep.id,
|
|
6382
6447
|
status: "failed",
|
|
6383
6448
|
method: "fallback",
|
|
6384
6449
|
duration: (selectorResult.duration || 0) + (aiLog?.duration || 0),
|
|
@@ -6391,7 +6456,7 @@ var HybridTestExecutor = class {
|
|
|
6391
6456
|
return result3;
|
|
6392
6457
|
}
|
|
6393
6458
|
const result2 = {
|
|
6394
|
-
stepId:
|
|
6459
|
+
stepId: enrichedStep.id,
|
|
6395
6460
|
status: "failed",
|
|
6396
6461
|
method: "selector",
|
|
6397
6462
|
duration: selectorResult.duration,
|
|
@@ -6403,20 +6468,10 @@ var HybridTestExecutor = class {
|
|
|
6403
6468
|
return result2;
|
|
6404
6469
|
}
|
|
6405
6470
|
if (hasAI) {
|
|
6406
|
-
const aiLog = await this.executeWithAI(
|
|
6407
|
-
|
|
6408
|
-
if (aiLog?.success && this.options.learnFromFallback && aiLog.element) {
|
|
6409
|
-
const strategies = generateSelectors(aiLog.element);
|
|
6410
|
-
if (strategies.length > 0) {
|
|
6411
|
-
learnedSelector = {
|
|
6412
|
-
stepId: step.id,
|
|
6413
|
-
elementDescription: aiLog.action.elementDescription || "",
|
|
6414
|
-
strategies
|
|
6415
|
-
};
|
|
6416
|
-
}
|
|
6417
|
-
}
|
|
6471
|
+
const aiLog = await this.executeWithAI(enrichedStep);
|
|
6472
|
+
const learnedSelector = aiLog?.success ? this.learnFromAIResult(enrichedStep.id, aiLog) : void 0;
|
|
6418
6473
|
const result2 = {
|
|
6419
|
-
stepId:
|
|
6474
|
+
stepId: enrichedStep.id,
|
|
6420
6475
|
status: aiLog?.success ? "passed" : "failed",
|
|
6421
6476
|
method: "ai",
|
|
6422
6477
|
duration: aiLog?.duration || 0,
|
|
@@ -6428,7 +6483,7 @@ var HybridTestExecutor = class {
|
|
|
6428
6483
|
return result2;
|
|
6429
6484
|
}
|
|
6430
6485
|
const result = {
|
|
6431
|
-
stepId:
|
|
6486
|
+
stepId: enrichedStep.id,
|
|
6432
6487
|
status: "failed",
|
|
6433
6488
|
method: "selector",
|
|
6434
6489
|
duration: 0,
|
|
@@ -6533,6 +6588,7 @@ var HybridTestExecutor = class {
|
|
|
6533
6588
|
async executeTestPlan(testPlan, options) {
|
|
6534
6589
|
const sequential = options?.sequential ?? true;
|
|
6535
6590
|
const results = [];
|
|
6591
|
+
await this.fetchLibraryIndex();
|
|
6536
6592
|
if (this.options.baseUrl && this.page) {
|
|
6537
6593
|
await this.page.goto(this.options.baseUrl, {
|
|
6538
6594
|
waitUntil: "domcontentloaded",
|
|
@@ -6556,6 +6612,206 @@ var HybridTestExecutor = class {
|
|
|
6556
6612
|
}
|
|
6557
6613
|
return results;
|
|
6558
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
|
+
const pageUrl = this.page?.url();
|
|
6625
|
+
if (pageUrl) {
|
|
6626
|
+
const tagName = aiLog.element.tagName?.toLowerCase();
|
|
6627
|
+
this.learnToLibrary(pageUrl, elementDescription, tagName, strategies).catch(() => {
|
|
6628
|
+
});
|
|
6629
|
+
this.addToLocalCache(pageUrl, elementDescription, strategies);
|
|
6630
|
+
}
|
|
6631
|
+
return { stepId, elementDescription, strategies, pageUrl };
|
|
6632
|
+
}
|
|
6633
|
+
/**
|
|
6634
|
+
* Call the selector-learn API to persist learned selectors to the library.
|
|
6635
|
+
* Fire-and-forget: failures are logged but do not affect execution.
|
|
6636
|
+
*/
|
|
6637
|
+
async learnToLibrary(pageUrl, elementDescription, elementRole, strategies) {
|
|
6638
|
+
const { apiBaseUrl, apiToken, projectId } = this.options;
|
|
6639
|
+
if (!apiBaseUrl || !apiToken || !projectId) return;
|
|
6640
|
+
try {
|
|
6641
|
+
const url = `${apiBaseUrl}/api/projects/${projectId}/selector-learn`;
|
|
6642
|
+
await fetch(url, {
|
|
6643
|
+
method: "POST",
|
|
6644
|
+
headers: {
|
|
6645
|
+
"Content-Type": "application/json",
|
|
6646
|
+
Authorization: `Bearer ${apiToken}`,
|
|
6647
|
+
"x-api-key": apiToken
|
|
6648
|
+
},
|
|
6649
|
+
body: JSON.stringify({
|
|
6650
|
+
pageUrl,
|
|
6651
|
+
learnedElements: [
|
|
6652
|
+
{
|
|
6653
|
+
elementDescription,
|
|
6654
|
+
elementRole,
|
|
6655
|
+
strategies
|
|
6656
|
+
}
|
|
6657
|
+
]
|
|
6658
|
+
}),
|
|
6659
|
+
signal: AbortSignal.timeout(1e4)
|
|
6660
|
+
});
|
|
6661
|
+
} catch {
|
|
6662
|
+
}
|
|
6663
|
+
}
|
|
6664
|
+
/**
|
|
6665
|
+
* Add a newly learned selector to the in-memory library index so subsequent
|
|
6666
|
+
* steps in the same run can use it without a fresh API fetch.
|
|
6667
|
+
*/
|
|
6668
|
+
addToLocalCache(pageUrl, elementDescription, strategies) {
|
|
6669
|
+
if (!this.libraryIndex) return;
|
|
6670
|
+
let urlPath;
|
|
6671
|
+
try {
|
|
6672
|
+
urlPath = new URL(pageUrl).pathname;
|
|
6673
|
+
} catch {
|
|
6674
|
+
urlPath = pageUrl;
|
|
6675
|
+
}
|
|
6676
|
+
urlPath = urlPath.replace(/\/$/, "") || "/";
|
|
6677
|
+
let page = this.libraryIndex.pages.find((p) => p.urlPattern === urlPath);
|
|
6678
|
+
if (!page) {
|
|
6679
|
+
page = { urlPattern: urlPath, elements: [] };
|
|
6680
|
+
this.libraryIndex.pages.push(page);
|
|
6681
|
+
}
|
|
6682
|
+
page.elements.push({
|
|
6683
|
+
name: elementDescription,
|
|
6684
|
+
aliases: [],
|
|
6685
|
+
elementRole: null,
|
|
6686
|
+
strategies: strategies.map((s, i) => ({
|
|
6687
|
+
type: s.type,
|
|
6688
|
+
value: s.value,
|
|
6689
|
+
playwrightLocator: s.playwrightLocator,
|
|
6690
|
+
confidence: s.confidence,
|
|
6691
|
+
rank: i
|
|
6692
|
+
}))
|
|
6693
|
+
});
|
|
6694
|
+
}
|
|
6695
|
+
/**
|
|
6696
|
+
* Fetch the full library export and cache it for the duration of plan execution.
|
|
6697
|
+
* Called once before executeTestPlan; failures are non-fatal.
|
|
6698
|
+
*/
|
|
6699
|
+
async fetchLibraryIndex() {
|
|
6700
|
+
const { apiBaseUrl, apiToken, projectId } = this.options;
|
|
6701
|
+
if (!apiBaseUrl || !apiToken || !projectId) return;
|
|
6702
|
+
try {
|
|
6703
|
+
const url = `${apiBaseUrl}/api/projects/${projectId}/selector-library/export`;
|
|
6704
|
+
const response = await fetch(url, {
|
|
6705
|
+
method: "GET",
|
|
6706
|
+
headers: {
|
|
6707
|
+
Authorization: `Bearer ${apiToken}`,
|
|
6708
|
+
"x-api-key": apiToken
|
|
6709
|
+
},
|
|
6710
|
+
signal: AbortSignal.timeout(15e3)
|
|
6711
|
+
});
|
|
6712
|
+
if (!response.ok) return;
|
|
6713
|
+
const json = await response.json();
|
|
6714
|
+
const exportData = json.data ?? json;
|
|
6715
|
+
if (!Array.isArray(exportData?.pages)) return;
|
|
6716
|
+
const pages = exportData.pages.map((p) => ({
|
|
6717
|
+
urlPattern: p.urlPattern,
|
|
6718
|
+
elements: (p.sections ?? []).flatMap(
|
|
6719
|
+
(s) => (s.elements ?? []).map((e) => ({
|
|
6720
|
+
name: e.name,
|
|
6721
|
+
aliases: e.aliases ?? [],
|
|
6722
|
+
elementRole: e.elementRole ?? null,
|
|
6723
|
+
strategies: (e.strategies ?? []).map((st) => ({
|
|
6724
|
+
type: st.type,
|
|
6725
|
+
value: st.value,
|
|
6726
|
+
playwrightLocator: st.playwrightLocator,
|
|
6727
|
+
confidence: st.confidence ?? 0.8,
|
|
6728
|
+
rank: st.rank ?? 0
|
|
6729
|
+
}))
|
|
6730
|
+
}))
|
|
6731
|
+
)
|
|
6732
|
+
}));
|
|
6733
|
+
this.libraryIndex = { pages, fetchedAt: Date.now() };
|
|
6734
|
+
} catch {
|
|
6735
|
+
}
|
|
6736
|
+
}
|
|
6737
|
+
/**
|
|
6738
|
+
* Enrich a test step's suggestedStrategies using the cached library index.
|
|
6739
|
+
* Finds matching elements by description and injects their strategies.
|
|
6740
|
+
*/
|
|
6741
|
+
enrichStepFromLibrary(step) {
|
|
6742
|
+
if (!this.libraryIndex) return step;
|
|
6743
|
+
if (step.action === "navigate") return step;
|
|
6744
|
+
const description = step.target?.elementDescription || step.description;
|
|
6745
|
+
if (!description) return step;
|
|
6746
|
+
const currentUrl = this.page?.url() ?? "";
|
|
6747
|
+
const descLower = description.toLowerCase().trim();
|
|
6748
|
+
let matchingPages = this.libraryIndex.pages;
|
|
6749
|
+
if (currentUrl) {
|
|
6750
|
+
try {
|
|
6751
|
+
const urlPath = new URL(currentUrl).pathname;
|
|
6752
|
+
const exactPageMatch = matchingPages.filter(
|
|
6753
|
+
(p) => urlPath === p.urlPattern || urlPath.startsWith(p.urlPattern)
|
|
6754
|
+
);
|
|
6755
|
+
if (exactPageMatch.length > 0) {
|
|
6756
|
+
matchingPages = exactPageMatch;
|
|
6757
|
+
}
|
|
6758
|
+
} catch {
|
|
6759
|
+
}
|
|
6760
|
+
}
|
|
6761
|
+
const allElements = matchingPages.flatMap((p) => p.elements);
|
|
6762
|
+
if (allElements.length === 0) return step;
|
|
6763
|
+
let bestMatch = null;
|
|
6764
|
+
let bestScore = 0;
|
|
6765
|
+
const THRESHOLD = 0.7;
|
|
6766
|
+
for (const el of allElements) {
|
|
6767
|
+
let score = 0;
|
|
6768
|
+
const elNameLower = el.name.toLowerCase();
|
|
6769
|
+
if (descLower === elNameLower) {
|
|
6770
|
+
score = 1;
|
|
6771
|
+
} else if (el.aliases.some((a) => a.toLowerCase() === descLower)) {
|
|
6772
|
+
score = 0.95;
|
|
6773
|
+
} else if (elNameLower.includes(descLower) || descLower.includes(elNameLower)) {
|
|
6774
|
+
score = 0.8;
|
|
6775
|
+
} else {
|
|
6776
|
+
const descWords = descLower.split(/\s+/);
|
|
6777
|
+
const nameWords = elNameLower.split(/\s+/);
|
|
6778
|
+
const overlap = descWords.filter((w) => nameWords.includes(w)).length;
|
|
6779
|
+
const maxWords = Math.max(descWords.length, nameWords.length);
|
|
6780
|
+
if (maxWords > 0) {
|
|
6781
|
+
score = 0.6 * (overlap / maxWords);
|
|
6782
|
+
}
|
|
6783
|
+
}
|
|
6784
|
+
if (el.elementRole && descLower.includes(el.elementRole.toLowerCase())) {
|
|
6785
|
+
score = Math.min(1, score + 0.1);
|
|
6786
|
+
}
|
|
6787
|
+
if (score > bestScore) {
|
|
6788
|
+
bestScore = score;
|
|
6789
|
+
bestMatch = el;
|
|
6790
|
+
}
|
|
6791
|
+
}
|
|
6792
|
+
if (!bestMatch || bestScore < THRESHOLD) return step;
|
|
6793
|
+
const libraryStrategies = bestMatch.strategies.sort((a, b) => a.rank - b.rank).map((s) => ({
|
|
6794
|
+
type: s.type,
|
|
6795
|
+
value: s.value,
|
|
6796
|
+
confidence: s.confidence,
|
|
6797
|
+
playwrightLocator: s.playwrightLocator
|
|
6798
|
+
}));
|
|
6799
|
+
if (libraryStrategies.length === 0) return step;
|
|
6800
|
+
const existingStrategies = step.target?.suggestedStrategies ?? [];
|
|
6801
|
+
const existingKeys = new Set(existingStrategies.map((s) => `${s.type}:${s.value}`));
|
|
6802
|
+
const mergedStrategies = [
|
|
6803
|
+
...libraryStrategies.filter((s) => !existingKeys.has(`${s.type}:${s.value}`)),
|
|
6804
|
+
...existingStrategies
|
|
6805
|
+
];
|
|
6806
|
+
return {
|
|
6807
|
+
...step,
|
|
6808
|
+
target: {
|
|
6809
|
+
elementDescription: step.target?.elementDescription || description,
|
|
6810
|
+
suggestedStrategies: mergedStrategies,
|
|
6811
|
+
elementId: step.target?.elementId
|
|
6812
|
+
}
|
|
6813
|
+
};
|
|
6814
|
+
}
|
|
6559
6815
|
/**
|
|
6560
6816
|
* Convert hybrid results to standard StepResult format
|
|
6561
6817
|
*/
|
|
@@ -6782,8 +7038,72 @@ function inferRoleFromDescription(description) {
|
|
|
6782
7038
|
}
|
|
6783
7039
|
return null;
|
|
6784
7040
|
}
|
|
7041
|
+
function createLocatorFromPlaywrightString(page, playwrightLocator) {
|
|
7042
|
+
try {
|
|
7043
|
+
const testidMatch = playwrightLocator.match(/^getByTestId\(['"](.+?)['"]\)$/);
|
|
7044
|
+
if (testidMatch) {
|
|
7045
|
+
return {
|
|
7046
|
+
locator: page.getByTestId(testidMatch[1]),
|
|
7047
|
+
selectorString: `testid="${testidMatch[1]}"`
|
|
7048
|
+
};
|
|
7049
|
+
}
|
|
7050
|
+
const roleWithNameMatch = playwrightLocator.match(
|
|
7051
|
+
/^getByRole\(['"](.+?)['"],\s*\{\s*name:\s*['"](.+?)['"]\s*\}\)$/
|
|
7052
|
+
);
|
|
7053
|
+
if (roleWithNameMatch) {
|
|
7054
|
+
return {
|
|
7055
|
+
locator: page.getByRole(roleWithNameMatch[1], {
|
|
7056
|
+
name: roleWithNameMatch[2]
|
|
7057
|
+
}),
|
|
7058
|
+
selectorString: `role=${roleWithNameMatch[1]}[name="${roleWithNameMatch[2]}"]`
|
|
7059
|
+
};
|
|
7060
|
+
}
|
|
7061
|
+
const roleMatch = playwrightLocator.match(/^getByRole\(['"](.+?)['"]\)$/);
|
|
7062
|
+
if (roleMatch) {
|
|
7063
|
+
return {
|
|
7064
|
+
locator: page.getByRole(roleMatch[1]),
|
|
7065
|
+
selectorString: `role=${roleMatch[1]}`
|
|
7066
|
+
};
|
|
7067
|
+
}
|
|
7068
|
+
const labelMatch = playwrightLocator.match(/^getByLabel\(['"](.+?)['"]\)$/);
|
|
7069
|
+
if (labelMatch) {
|
|
7070
|
+
return {
|
|
7071
|
+
locator: page.getByLabel(labelMatch[1]),
|
|
7072
|
+
selectorString: `label="${labelMatch[1]}"`
|
|
7073
|
+
};
|
|
7074
|
+
}
|
|
7075
|
+
const placeholderMatch = playwrightLocator.match(/^getByPlaceholder\(['"](.+?)['"]\)$/);
|
|
7076
|
+
if (placeholderMatch) {
|
|
7077
|
+
return {
|
|
7078
|
+
locator: page.getByPlaceholder(placeholderMatch[1]),
|
|
7079
|
+
selectorString: `placeholder="${placeholderMatch[1]}"`
|
|
7080
|
+
};
|
|
7081
|
+
}
|
|
7082
|
+
const textMatch = playwrightLocator.match(/^getByText\(['"](.+?)['"]\)$/);
|
|
7083
|
+
if (textMatch) {
|
|
7084
|
+
return {
|
|
7085
|
+
locator: page.getByText(textMatch[1]),
|
|
7086
|
+
selectorString: `text="${textMatch[1]}"`
|
|
7087
|
+
};
|
|
7088
|
+
}
|
|
7089
|
+
const locatorMatch = playwrightLocator.match(/^locator\(['"](.+?)['"]\)$/);
|
|
7090
|
+
if (locatorMatch) {
|
|
7091
|
+
return {
|
|
7092
|
+
locator: page.locator(locatorMatch[1]),
|
|
7093
|
+
selectorString: `css=${locatorMatch[1]}`
|
|
7094
|
+
};
|
|
7095
|
+
}
|
|
7096
|
+
return null;
|
|
7097
|
+
} catch {
|
|
7098
|
+
return null;
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
6785
7101
|
async function createLocatorForStrategy(page, strategy, options) {
|
|
6786
7102
|
const { exact = false } = options;
|
|
7103
|
+
if (strategy.playwrightLocator) {
|
|
7104
|
+
const result = createLocatorFromPlaywrightString(page, strategy.playwrightLocator);
|
|
7105
|
+
if (result) return result;
|
|
7106
|
+
}
|
|
6787
7107
|
switch (strategy.type) {
|
|
6788
7108
|
case "role": {
|
|
6789
7109
|
const [role, ...nameParts] = strategy.value.split(":");
|
|
@@ -6862,7 +7182,7 @@ function sortStrategies(strategies) {
|
|
|
6862
7182
|
return b.confidence - a.confidence;
|
|
6863
7183
|
});
|
|
6864
7184
|
}
|
|
6865
|
-
async function
|
|
7185
|
+
async function resolveSelector2(page, hint, options = {}) {
|
|
6866
7186
|
const { timeout = 5e3, maxAttempts } = options;
|
|
6867
7187
|
const attemptedStrategies = [];
|
|
6868
7188
|
const sortedStrategies = sortStrategies(hint.suggestedStrategies);
|
|
@@ -7014,7 +7334,7 @@ async function resolveTarget(page, step, timeout) {
|
|
|
7014
7334
|
step.target.elementDescription
|
|
7015
7335
|
)
|
|
7016
7336
|
};
|
|
7017
|
-
return
|
|
7337
|
+
return resolveSelector2(page, hint, { timeout });
|
|
7018
7338
|
}
|
|
7019
7339
|
async function handleNavigate(page, step, options) {
|
|
7020
7340
|
const { timeout = 3e4 } = options;
|
|
@@ -7692,6 +8012,7 @@ async function executeWithSelectors(testPlan, options) {
|
|
|
7692
8012
|
|
|
7693
8013
|
// src/commands/run/executors/hybrid-executor.ts
|
|
7694
8014
|
import chalk13 from "chalk";
|
|
8015
|
+
init_config();
|
|
7695
8016
|
async function executeWithHybrid(testPlan, options) {
|
|
7696
8017
|
const { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential } = options;
|
|
7697
8018
|
const config = await getCliConfig().catch(() => null);
|
|
@@ -7703,6 +8024,8 @@ async function executeWithHybrid(testPlan, options) {
|
|
|
7703
8024
|
}
|
|
7704
8025
|
const learnedSelectors = [];
|
|
7705
8026
|
const modelOverride = process.env.AI_MODEL;
|
|
8027
|
+
const apiBaseUrl = getApiUrl();
|
|
8028
|
+
const apiToken = getToken();
|
|
7706
8029
|
const hybridExecutor = new HybridTestExecutor({
|
|
7707
8030
|
baseUrl: url,
|
|
7708
8031
|
headed,
|
|
@@ -7714,6 +8037,10 @@ async function executeWithHybrid(testPlan, options) {
|
|
|
7714
8037
|
storageStatePath,
|
|
7715
8038
|
actionModel: modelOverride || void 0,
|
|
7716
8039
|
assertionModel: modelOverride || void 0,
|
|
8040
|
+
// Selector Library integration: AI agent will call /selector-learn after successful fallbacks
|
|
8041
|
+
apiBaseUrl: apiBaseUrl || void 0,
|
|
8042
|
+
apiToken: apiToken || void 0,
|
|
8043
|
+
projectId: options.projectId || void 0,
|
|
7717
8044
|
visionClientOptions: apiKey ? {
|
|
7718
8045
|
apiKey,
|
|
7719
8046
|
timeout: 3e4
|
|
@@ -8025,8 +8352,8 @@ function displayHybridSummary(summary, results, isPretty) {
|
|
|
8025
8352
|
// src/commands/run/executors/selector-learning.ts
|
|
8026
8353
|
import chalk15 from "chalk";
|
|
8027
8354
|
async function handleAISelectorLearning(learnedSelectors, options) {
|
|
8028
|
-
const { projectId,
|
|
8029
|
-
if (!
|
|
8355
|
+
const { projectId, learnSelectors: shouldLearn, yes, verbose, isPretty, targetUrl } = options;
|
|
8356
|
+
if (!shouldLearn) {
|
|
8030
8357
|
return;
|
|
8031
8358
|
}
|
|
8032
8359
|
if (learnedSelectors.length === 0) {
|
|
@@ -8038,41 +8365,26 @@ async function handleAISelectorLearning(learnedSelectors, options) {
|
|
|
8038
8365
|
if (isPretty) {
|
|
8039
8366
|
displayLearnedSelectors(learnedSelectors, verbose);
|
|
8040
8367
|
}
|
|
8041
|
-
if (projectId
|
|
8368
|
+
if (projectId) {
|
|
8042
8369
|
let shouldSave = yes;
|
|
8043
8370
|
if (!shouldSave && isPretty) {
|
|
8044
8371
|
shouldSave = await promptConfirmation(
|
|
8045
|
-
chalk15.yellow("Save learned selectors to
|
|
8372
|
+
chalk15.yellow("Save learned selectors to library?")
|
|
8046
8373
|
);
|
|
8047
8374
|
}
|
|
8048
8375
|
if (shouldSave) {
|
|
8049
|
-
|
|
8050
|
-
try {
|
|
8051
|
-
const saveResult = await saveLearnedSelectors(
|
|
8052
|
-
projectId,
|
|
8053
|
-
testCaseId,
|
|
8054
|
-
learnedSelectors
|
|
8055
|
-
);
|
|
8056
|
-
saveSpinner?.succeed(
|
|
8057
|
-
`Saved selectors for ${saveResult.updatedSteps} step${saveResult.updatedSteps === 1 ? "" : "s"}`
|
|
8058
|
-
);
|
|
8059
|
-
} catch (saveError) {
|
|
8060
|
-
saveSpinner?.fail("Failed to save learned selectors");
|
|
8061
|
-
if (verbose && saveError instanceof Error) {
|
|
8062
|
-
console.warn(chalk15.yellow(` ${saveError.message}`));
|
|
8063
|
-
}
|
|
8064
|
-
}
|
|
8376
|
+
await saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose);
|
|
8065
8377
|
} else if (isPretty) {
|
|
8066
8378
|
log.dim("Selector learning skipped.");
|
|
8067
8379
|
}
|
|
8068
8380
|
} else if (isPretty) {
|
|
8069
|
-
log.dim("Selectors displayed but not saved (no
|
|
8070
|
-
log.dim("Use --project
|
|
8381
|
+
log.dim("Selectors displayed but not saved (no project configured).");
|
|
8382
|
+
log.dim("Use --project to save selectors to the library.");
|
|
8071
8383
|
}
|
|
8072
8384
|
}
|
|
8073
8385
|
async function handleHybridSelectorLearning(learnedSelectors, options) {
|
|
8074
|
-
const { projectId,
|
|
8075
|
-
if (!
|
|
8386
|
+
const { projectId, learnSelectors: shouldLearn, yes, verbose, isPretty, targetUrl } = options;
|
|
8387
|
+
if (!shouldLearn) {
|
|
8076
8388
|
return;
|
|
8077
8389
|
}
|
|
8078
8390
|
if (learnedSelectors.length === 0) {
|
|
@@ -8084,36 +8396,57 @@ async function handleHybridSelectorLearning(learnedSelectors, options) {
|
|
|
8084
8396
|
if (isPretty) {
|
|
8085
8397
|
displayLearnedSelectors(learnedSelectors, verbose);
|
|
8086
8398
|
}
|
|
8087
|
-
if (projectId
|
|
8399
|
+
if (projectId) {
|
|
8088
8400
|
let shouldSave = yes;
|
|
8089
8401
|
if (!shouldSave && isPretty) {
|
|
8090
8402
|
shouldSave = await promptConfirmation(
|
|
8091
|
-
chalk15.yellow("Save learned selectors to
|
|
8403
|
+
chalk15.yellow("Save learned selectors to library?")
|
|
8092
8404
|
);
|
|
8093
8405
|
}
|
|
8094
8406
|
if (shouldSave) {
|
|
8095
|
-
|
|
8096
|
-
try {
|
|
8097
|
-
const saveResult = await saveLearnedSelectors(
|
|
8098
|
-
projectId,
|
|
8099
|
-
testCaseId,
|
|
8100
|
-
learnedSelectors
|
|
8101
|
-
);
|
|
8102
|
-
saveSpinner?.succeed(
|
|
8103
|
-
`Saved selectors for ${saveResult.updatedSteps} step${saveResult.updatedSteps === 1 ? "" : "s"}`
|
|
8104
|
-
);
|
|
8105
|
-
} catch (saveError) {
|
|
8106
|
-
saveSpinner?.fail("Failed to save learned selectors");
|
|
8107
|
-
if (verbose && saveError instanceof Error) {
|
|
8108
|
-
console.warn(chalk15.yellow(` ${saveError.message}`));
|
|
8109
|
-
}
|
|
8110
|
-
}
|
|
8407
|
+
await saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose);
|
|
8111
8408
|
} else if (isPretty) {
|
|
8112
8409
|
log.dim("Selector learning skipped.");
|
|
8113
8410
|
}
|
|
8114
8411
|
} else if (isPretty) {
|
|
8115
|
-
log.dim("Selectors displayed but not saved (no
|
|
8116
|
-
log.dim("Use --project
|
|
8412
|
+
log.dim("Selectors displayed but not saved (no project configured).");
|
|
8413
|
+
log.dim("Use --project to save selectors to the library.");
|
|
8414
|
+
}
|
|
8415
|
+
}
|
|
8416
|
+
async function saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose) {
|
|
8417
|
+
const saveSpinner = isPretty ? createSpinner("Saving selectors to library...").start() : null;
|
|
8418
|
+
try {
|
|
8419
|
+
const groups = /* @__PURE__ */ new Map();
|
|
8420
|
+
for (const ls of learnedSelectors) {
|
|
8421
|
+
const url = ls.pageUrl || targetUrl || "";
|
|
8422
|
+
if (!url) continue;
|
|
8423
|
+
if (!groups.has(url)) groups.set(url, []);
|
|
8424
|
+
groups.get(url).push(ls);
|
|
8425
|
+
}
|
|
8426
|
+
let totalCount = 0;
|
|
8427
|
+
for (const [pageUrl, selectors] of groups) {
|
|
8428
|
+
const elements = selectors.map((ls) => ({
|
|
8429
|
+
elementDescription: ls.elementDescription || "Unknown element",
|
|
8430
|
+
strategies: ls.strategies.map((s) => ({
|
|
8431
|
+
type: s.type || "css",
|
|
8432
|
+
value: s.value || "",
|
|
8433
|
+
playwrightLocator: s.playwrightLocator || s.value || "",
|
|
8434
|
+
confidence: s.confidence ?? 0.8
|
|
8435
|
+
}))
|
|
8436
|
+
}));
|
|
8437
|
+
const validElements = elements.filter((e) => e.strategies.length > 0);
|
|
8438
|
+
if (validElements.length === 0) continue;
|
|
8439
|
+
const result = await learnSelectors(projectId, pageUrl, validElements);
|
|
8440
|
+
totalCount += result.created.elements + result.updated.elements;
|
|
8441
|
+
}
|
|
8442
|
+
saveSpinner?.succeed(
|
|
8443
|
+
`Saved ${totalCount} selector${totalCount === 1 ? "" : "s"} to library`
|
|
8444
|
+
);
|
|
8445
|
+
} catch (saveError) {
|
|
8446
|
+
saveSpinner?.fail("Failed to save selectors to library");
|
|
8447
|
+
if (verbose && saveError instanceof Error) {
|
|
8448
|
+
console.warn(chalk15.yellow(` ${saveError.message}`));
|
|
8449
|
+
}
|
|
8117
8450
|
}
|
|
8118
8451
|
}
|
|
8119
8452
|
|
|
@@ -8133,7 +8466,7 @@ async function runCommand(options) {
|
|
|
8133
8466
|
format = "pretty",
|
|
8134
8467
|
verbose = false,
|
|
8135
8468
|
timeout = 3e4,
|
|
8136
|
-
learnSelectors = false,
|
|
8469
|
+
learnSelectors: learnSelectors2 = false,
|
|
8137
8470
|
yes = false,
|
|
8138
8471
|
noDedup = false,
|
|
8139
8472
|
autoMerge = false,
|
|
@@ -8141,6 +8474,7 @@ async function runCommand(options) {
|
|
|
8141
8474
|
recordTrace = false,
|
|
8142
8475
|
env: envName,
|
|
8143
8476
|
credential: credentialName,
|
|
8477
|
+
feature: featureFlag,
|
|
8144
8478
|
sequential = true
|
|
8145
8479
|
} = mergedOptions;
|
|
8146
8480
|
const mode = file && !requestedMode ? "ai" : requestedMode || "selector";
|
|
@@ -8155,7 +8489,28 @@ async function runCommand(options) {
|
|
|
8155
8489
|
}
|
|
8156
8490
|
const projectId = project || getActiveProject();
|
|
8157
8491
|
let url = rawUrl;
|
|
8158
|
-
|
|
8492
|
+
let resolvedEnvName = envName || projectConfig?.environment || "local";
|
|
8493
|
+
let envAuth = projectConfig?.auth;
|
|
8494
|
+
const envConfig = resolveEnvironmentConfig(envName || projectConfig?.environment, projectConfig);
|
|
8495
|
+
if (envConfig) {
|
|
8496
|
+
if (!rawUrl) {
|
|
8497
|
+
url = envConfig.baseUrl || rawUrl;
|
|
8498
|
+
}
|
|
8499
|
+
resolvedEnvName = envConfig.envName;
|
|
8500
|
+
if (envConfig.auth) {
|
|
8501
|
+
envAuth = envConfig.auth;
|
|
8502
|
+
}
|
|
8503
|
+
if (envConfig.apiUrl) {
|
|
8504
|
+
overrideApiUrl(envConfig.apiUrl);
|
|
8505
|
+
}
|
|
8506
|
+
if (isPretty) {
|
|
8507
|
+
log.info(`Using environment "${envConfig.envName}" ${chalk16.dim("(local)")}: ${chalk16.cyan(envConfig.baseUrl || "N/A")}`);
|
|
8508
|
+
if (envConfig.apiUrl) {
|
|
8509
|
+
log.info(` API: ${chalk16.cyan(envConfig.apiUrl)}`);
|
|
8510
|
+
}
|
|
8511
|
+
log.newline();
|
|
8512
|
+
}
|
|
8513
|
+
} else if (envName) {
|
|
8159
8514
|
try {
|
|
8160
8515
|
if (isPretty) {
|
|
8161
8516
|
log.info(`Resolving environment "${envName}"...`);
|
|
@@ -8208,11 +8563,10 @@ async function runCommand(options) {
|
|
|
8208
8563
|
}
|
|
8209
8564
|
}
|
|
8210
8565
|
let storageStatePath;
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
const
|
|
8214
|
-
const
|
|
8215
|
-
const credentials = { ...authConfig.credentials };
|
|
8566
|
+
if (envAuth) {
|
|
8567
|
+
const statePath = path7.resolve(envAuth.storageStatePath || DEFAULT_STATE_PATH);
|
|
8568
|
+
const maxAge = envAuth.maxAge || DEFAULT_MAX_AGE;
|
|
8569
|
+
const credentials = { ...envAuth.credentials };
|
|
8216
8570
|
if (AuthExecutor.hasFreshState(statePath, maxAge)) {
|
|
8217
8571
|
if (isPretty) {
|
|
8218
8572
|
log.dim(" Using cached auth state");
|
|
@@ -8224,7 +8578,7 @@ async function runCommand(options) {
|
|
|
8224
8578
|
}
|
|
8225
8579
|
try {
|
|
8226
8580
|
const targetUrl = url || projectConfig?.baseUrl || "";
|
|
8227
|
-
storageStatePath = await AuthExecutor.authenticate(
|
|
8581
|
+
storageStatePath = await AuthExecutor.authenticate(envAuth, targetUrl, credentials);
|
|
8228
8582
|
if (isPretty) {
|
|
8229
8583
|
console.log(chalk16.green(" \u2713 Authenticated"));
|
|
8230
8584
|
}
|
|
@@ -8237,6 +8591,34 @@ async function runCommand(options) {
|
|
|
8237
8591
|
}
|
|
8238
8592
|
}
|
|
8239
8593
|
}
|
|
8594
|
+
let resolvedFeatureId;
|
|
8595
|
+
if (featureFlag && projectId) {
|
|
8596
|
+
try {
|
|
8597
|
+
const { features } = await listFeatures(projectId);
|
|
8598
|
+
const byId = features.find((f) => f.id === featureFlag);
|
|
8599
|
+
if (byId) {
|
|
8600
|
+
resolvedFeatureId = byId.id;
|
|
8601
|
+
if (isPretty) log.info(`Using feature "${byId.name}" ${chalk16.dim(`(${byId.id.slice(0, 8)}...)`)}`);
|
|
8602
|
+
} else {
|
|
8603
|
+
const byName = features.find((f) => f.name.toLowerCase() === featureFlag.toLowerCase());
|
|
8604
|
+
if (byName) {
|
|
8605
|
+
resolvedFeatureId = byName.id;
|
|
8606
|
+
if (isPretty) log.info(`Using feature "${byName.name}" ${chalk16.dim(`(${byName.id.slice(0, 8)}...)`)}`);
|
|
8607
|
+
} else {
|
|
8608
|
+
if (isPretty) log.info(`Creating feature "${featureFlag}"...`);
|
|
8609
|
+
const { feature: newFeature } = await createFeature(projectId, { name: featureFlag });
|
|
8610
|
+
resolvedFeatureId = newFeature.id;
|
|
8611
|
+
if (isPretty) log.info(`Created feature "${newFeature.name}" ${chalk16.dim(`(${newFeature.id.slice(0, 8)}...)`)}`);
|
|
8612
|
+
}
|
|
8613
|
+
}
|
|
8614
|
+
if (isPretty) log.newline();
|
|
8615
|
+
} catch (error) {
|
|
8616
|
+
if (isPretty) {
|
|
8617
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8618
|
+
log.warn(`Failed to resolve feature "${featureFlag}": ${message}`);
|
|
8619
|
+
}
|
|
8620
|
+
}
|
|
8621
|
+
}
|
|
8240
8622
|
const { generatedFromFile, fileGeneratedTestCases } = await handleFileOption(
|
|
8241
8623
|
file,
|
|
8242
8624
|
projectId,
|
|
@@ -8264,7 +8646,10 @@ async function runCommand(options) {
|
|
|
8264
8646
|
generatedFromFile,
|
|
8265
8647
|
fileGeneratedTestCases,
|
|
8266
8648
|
projectConfig,
|
|
8267
|
-
isPretty
|
|
8649
|
+
isPretty,
|
|
8650
|
+
environment: resolvedEnvName,
|
|
8651
|
+
authEnabled: !!envAuth,
|
|
8652
|
+
featureId: resolvedFeatureId
|
|
8268
8653
|
});
|
|
8269
8654
|
await runTestExecution({
|
|
8270
8655
|
testPlan,
|
|
@@ -8278,7 +8663,7 @@ async function runCommand(options) {
|
|
|
8278
8663
|
reporter,
|
|
8279
8664
|
projectId,
|
|
8280
8665
|
testCaseId,
|
|
8281
|
-
learnSelectors,
|
|
8666
|
+
learnSelectors: learnSelectors2,
|
|
8282
8667
|
yes,
|
|
8283
8668
|
recordVideo,
|
|
8284
8669
|
recordTrace,
|
|
@@ -8335,7 +8720,7 @@ async function handleFileOption(file, projectId, isPretty, verbose, noDedup, aut
|
|
|
8335
8720
|
}
|
|
8336
8721
|
}
|
|
8337
8722
|
async function runTestExecution(options) {
|
|
8338
|
-
const { testPlan, runId, mode, url, headed, timeout, verbose, isPretty, reporter, projectId, testCaseId, learnSelectors, yes, recordVideo, recordTrace, storageStatePath, sequential } = options;
|
|
8723
|
+
const { testPlan, runId, mode, url, headed, timeout, verbose, isPretty, reporter, projectId, testCaseId, learnSelectors: learnSelectors2, yes, recordVideo, recordTrace, storageStatePath, sequential } = options;
|
|
8339
8724
|
if (isPretty) {
|
|
8340
8725
|
log.newline();
|
|
8341
8726
|
const modeLabel = mode === "ai" ? chalk16.magenta("[AI Mode]") : mode === "hybrid" ? chalk16.yellow("[Hybrid Mode]") : chalk16.cyan("[Selector Mode]");
|
|
@@ -8362,15 +8747,15 @@ async function runTestExecution(options) {
|
|
|
8362
8747
|
await submitAICorrections(aiResults.results, testPlan, projectId, isPretty, verbose);
|
|
8363
8748
|
}
|
|
8364
8749
|
const learnedSelectors = extractLearnedSelectors(aiResults.results);
|
|
8365
|
-
await handleAISelectorLearning(learnedSelectors, { projectId, testCaseId, learnSelectors, yes, verbose, isPretty });
|
|
8750
|
+
await handleAISelectorLearning(learnedSelectors, { projectId, testCaseId, learnSelectors: learnSelectors2, yes, verbose, isPretty, targetUrl: url });
|
|
8366
8751
|
} else if (mode === "hybrid") {
|
|
8367
|
-
const hybridResults = await executeWithHybrid(testPlan, { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential });
|
|
8752
|
+
const hybridResults = await executeWithHybrid(testPlan, { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential, projectId });
|
|
8368
8753
|
summary = hybridResults.summary;
|
|
8369
8754
|
videoPath = hybridResults.videoPath;
|
|
8370
8755
|
tracePath = hybridResults.tracePath;
|
|
8371
8756
|
await submitHybridResults(runId, hybridResults.results, isPretty, verbose);
|
|
8372
8757
|
displayHybridSummary(summary, hybridResults.results, isPretty);
|
|
8373
|
-
await handleHybridSelectorLearning(hybridResults.learnedSelectors, { projectId, testCaseId, learnSelectors, yes, verbose, isPretty });
|
|
8758
|
+
await handleHybridSelectorLearning(hybridResults.learnedSelectors, { projectId, testCaseId, learnSelectors: learnSelectors2, yes, verbose, isPretty, targetUrl: url });
|
|
8374
8759
|
} else {
|
|
8375
8760
|
const results = await executeWithSelectors(testPlan, { url, headed, timeout, isPretty, reporter, recordVideo, recordTrace, storageStatePath, sequential });
|
|
8376
8761
|
summary = { passed: results.summary.passed, failed: results.summary.failed, total: results.summary.total };
|
|
@@ -9505,14 +9890,13 @@ async function featuresListCommand() {
|
|
|
9505
9890
|
const maxSlugLen = Math.max(4, ...features.map((f) => f.slug.length));
|
|
9506
9891
|
console.log(chalk24.bold("Features:"));
|
|
9507
9892
|
console.log(
|
|
9508
|
-
` ${chalk24.dim("NAME".padEnd(maxNameLen))} ${chalk24.dim("SLUG".padEnd(maxSlugLen))} ${chalk24.dim("TEST CASES")}
|
|
9893
|
+
` ${chalk24.dim("NAME".padEnd(maxNameLen))} ${chalk24.dim("SLUG".padEnd(maxSlugLen))} ${chalk24.dim("TEST CASES")}`
|
|
9509
9894
|
);
|
|
9510
9895
|
for (const feature of features) {
|
|
9511
9896
|
const name = feature.name.padEnd(maxNameLen);
|
|
9512
9897
|
const slug = feature.slug.padEnd(maxSlugLen);
|
|
9513
9898
|
const tcCount = String(feature.testCaseCount ?? 0).padStart(5);
|
|
9514
|
-
|
|
9515
|
-
console.log(` ${name} ${chalk24.dim(slug)} ${tcCount} ${selCount}`);
|
|
9899
|
+
console.log(` ${name} ${chalk24.dim(slug)} ${tcCount}`);
|
|
9516
9900
|
}
|
|
9517
9901
|
log.newline();
|
|
9518
9902
|
} catch (error) {
|
|
@@ -9573,113 +9957,590 @@ async function featuresAssignCommand(featureSlug, options) {
|
|
|
9573
9957
|
// src/commands/selectors.ts
|
|
9574
9958
|
import * as fs9 from "fs";
|
|
9575
9959
|
import chalk25 from "chalk";
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
}
|
|
9582
|
-
async function
|
|
9960
|
+
async function findPageByUrl(projectId, urlPattern) {
|
|
9961
|
+
const { pages } = await listSelectorPages(projectId);
|
|
9962
|
+
return pages.find(
|
|
9963
|
+
(p) => p.urlPattern === urlPattern || p.urlPattern === `/${urlPattern.replace(/^\//, "")}`
|
|
9964
|
+
);
|
|
9965
|
+
}
|
|
9966
|
+
async function findElementByName(projectId, elementName, pageUrlPattern) {
|
|
9967
|
+
let pageId;
|
|
9968
|
+
if (pageUrlPattern) {
|
|
9969
|
+
const page = await findPageByUrl(projectId, pageUrlPattern);
|
|
9970
|
+
if (page) pageId = page.id;
|
|
9971
|
+
}
|
|
9972
|
+
const { elements } = await listSelectorElements(projectId, { pageId });
|
|
9973
|
+
return elements.find(
|
|
9974
|
+
(e) => e.name.toLowerCase() === elementName.toLowerCase()
|
|
9975
|
+
);
|
|
9976
|
+
}
|
|
9977
|
+
async function selectorsPagesCommand(options) {
|
|
9583
9978
|
const projectId = requireAuthAndProject();
|
|
9584
|
-
|
|
9979
|
+
if (options.page) {
|
|
9980
|
+
return selectorsPageDetailCommand(projectId, options.page);
|
|
9981
|
+
}
|
|
9982
|
+
const spinner = createSpinner("Fetching selector library...").start();
|
|
9585
9983
|
try {
|
|
9586
|
-
const {
|
|
9587
|
-
featureSlug: options.feature,
|
|
9588
|
-
status: options.status
|
|
9589
|
-
});
|
|
9984
|
+
const { pages } = await listSelectorPages(projectId);
|
|
9590
9985
|
spinner.succeed(
|
|
9591
|
-
`Found ${
|
|
9986
|
+
`Found ${pages.length} page${pages.length === 1 ? "" : "s"}`
|
|
9592
9987
|
);
|
|
9593
9988
|
log.newline();
|
|
9594
|
-
if (
|
|
9595
|
-
log.plain("No
|
|
9989
|
+
if (pages.length === 0) {
|
|
9990
|
+
log.plain("No selector pages found.");
|
|
9596
9991
|
log.newline();
|
|
9597
|
-
log.dim("Run tests to auto-discover selectors
|
|
9992
|
+
log.dim("Run tests to auto-discover selectors, or add pages manually:");
|
|
9993
|
+
log.dim(' testbro selectors add-page --name "Login Page" --url-pattern "/login"');
|
|
9598
9994
|
log.newline();
|
|
9599
9995
|
return;
|
|
9600
9996
|
}
|
|
9601
|
-
const
|
|
9602
|
-
|
|
9603
|
-
|
|
9604
|
-
)
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
|
|
9608
|
-
|
|
9609
|
-
|
|
9997
|
+
const { elements } = await listSelectorElements(projectId);
|
|
9998
|
+
const pageElementCounts = /* @__PURE__ */ new Map();
|
|
9999
|
+
const pageSectionIds = /* @__PURE__ */ new Map();
|
|
10000
|
+
for (const el of elements) {
|
|
10001
|
+
const pageUrl = el.section?.page?.urlPattern;
|
|
10002
|
+
const pageId = el.section?.page?.id;
|
|
10003
|
+
if (pageId) {
|
|
10004
|
+
pageElementCounts.set(pageId, (pageElementCounts.get(pageId) || 0) + 1);
|
|
10005
|
+
if (!pageSectionIds.has(pageId)) pageSectionIds.set(pageId, /* @__PURE__ */ new Set());
|
|
10006
|
+
pageSectionIds.get(pageId).add(el.sectionId);
|
|
10007
|
+
}
|
|
10008
|
+
}
|
|
10009
|
+
const maxNameLen = Math.max(4, ...pages.map((p) => p.name.length));
|
|
10010
|
+
const maxUrlLen = Math.max(11, ...pages.map((p) => p.urlPattern.length));
|
|
10011
|
+
console.log(chalk25.bold("Selector Pages:"));
|
|
9610
10012
|
console.log(
|
|
9611
|
-
` ${chalk25.dim("
|
|
10013
|
+
` ${chalk25.dim("NAME".padEnd(maxNameLen))} ${chalk25.dim("URL PATTERN".padEnd(maxUrlLen))} ${chalk25.dim("SECTIONS")} ${chalk25.dim("ELEMENTS")}`
|
|
9612
10014
|
);
|
|
9613
|
-
for (const
|
|
9614
|
-
const
|
|
9615
|
-
const
|
|
9616
|
-
const
|
|
9617
|
-
const
|
|
9618
|
-
|
|
9619
|
-
|
|
10015
|
+
for (const page of pages) {
|
|
10016
|
+
const name = page.name.padEnd(maxNameLen);
|
|
10017
|
+
const url = page.urlPattern.padEnd(maxUrlLen);
|
|
10018
|
+
const sectionCount = page._count?.sections ?? 0;
|
|
10019
|
+
const elementCount = pageElementCounts.get(page.id) ?? 0;
|
|
10020
|
+
console.log(
|
|
10021
|
+
` ${name} ${chalk25.dim(url)} ${String(sectionCount).padStart(8)} ${String(elementCount).padStart(8)}`
|
|
10022
|
+
);
|
|
9620
10023
|
}
|
|
9621
10024
|
log.newline();
|
|
9622
10025
|
} catch (error) {
|
|
9623
|
-
spinner.fail("Failed to fetch
|
|
10026
|
+
spinner.fail("Failed to fetch selector library");
|
|
9624
10027
|
handleCliError(error);
|
|
9625
10028
|
process.exit(1);
|
|
9626
10029
|
}
|
|
9627
10030
|
}
|
|
9628
|
-
async function
|
|
9629
|
-
const
|
|
9630
|
-
const selectorIds = options.ids ? options.ids.split(",").map((id) => id.trim()).filter(Boolean) : void 0;
|
|
9631
|
-
const label = selectorIds ? `Re-verifying ${selectorIds.length} selector${selectorIds.length === 1 ? "" : "s"}...` : "Re-verifying all selectors...";
|
|
9632
|
-
const spinner = createSpinner(label).start();
|
|
10031
|
+
async function selectorsPageDetailCommand(projectId, pageUrlPattern) {
|
|
10032
|
+
const spinner = createSpinner(`Fetching page "${pageUrlPattern}"...`).start();
|
|
9633
10033
|
try {
|
|
9634
|
-
const
|
|
9635
|
-
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
10034
|
+
const page = await findPageByUrl(projectId, pageUrlPattern);
|
|
10035
|
+
if (!page) {
|
|
10036
|
+
spinner.fail(`Page with URL pattern "${pageUrlPattern}" not found`);
|
|
10037
|
+
return;
|
|
10038
|
+
}
|
|
10039
|
+
const { elements } = await listSelectorElements(projectId, { pageId: page.id });
|
|
10040
|
+
spinner.succeed(`${page.name} (${page.urlPattern})`);
|
|
9641
10041
|
log.newline();
|
|
9642
|
-
|
|
9643
|
-
|
|
10042
|
+
if (elements.length === 0) {
|
|
10043
|
+
log.plain("No elements found for this page.");
|
|
10044
|
+
log.newline();
|
|
10045
|
+
return;
|
|
10046
|
+
}
|
|
10047
|
+
const sectionMap = /* @__PURE__ */ new Map();
|
|
10048
|
+
for (const el of elements) {
|
|
10049
|
+
const sectionId = el.sectionId;
|
|
10050
|
+
if (!sectionMap.has(sectionId)) sectionMap.set(sectionId, []);
|
|
10051
|
+
sectionMap.get(sectionId).push(el);
|
|
10052
|
+
}
|
|
10053
|
+
const sectionNames = /* @__PURE__ */ new Map();
|
|
10054
|
+
for (const el of elements) {
|
|
10055
|
+
const sectionId = el.sectionId;
|
|
10056
|
+
if (!sectionNames.has(sectionId)) {
|
|
10057
|
+
sectionNames.set(sectionId, sectionId);
|
|
10058
|
+
}
|
|
10059
|
+
}
|
|
10060
|
+
for (const [sectionId, sectionElements] of sectionMap) {
|
|
10061
|
+
const sectionName = sectionNames.get(sectionId) || "Unknown Section";
|
|
10062
|
+
console.log(` ${chalk25.bold.underline(sectionName)}`);
|
|
10063
|
+
for (const el of sectionElements) {
|
|
10064
|
+
const topStrategy = el.strategies[0];
|
|
10065
|
+
const strategyStr = topStrategy ? `${topStrategy.type}="${topStrategy.value}"` : chalk25.dim("no strategies");
|
|
10066
|
+
const statusIcon2 = el.status === "active" ? icons.success : el.status === "broken" ? icons.fail : chalk25.yellow("~");
|
|
10067
|
+
const totalSuccess = el.strategies.reduce((sum, s) => sum + s.successCount, 0);
|
|
10068
|
+
console.log(
|
|
10069
|
+
` ${statusIcon2} ${el.name.padEnd(25)} ${chalk25.dim(strategyStr.padEnd(35))} ${chalk25.dim(`${totalSuccess} successes`)}`
|
|
10070
|
+
);
|
|
10071
|
+
}
|
|
10072
|
+
log.newline();
|
|
10073
|
+
}
|
|
10074
|
+
} catch (error) {
|
|
10075
|
+
spinner.fail("Failed to fetch page details");
|
|
9644
10076
|
handleCliError(error);
|
|
9645
10077
|
process.exit(1);
|
|
9646
10078
|
}
|
|
9647
10079
|
}
|
|
9648
|
-
async function
|
|
10080
|
+
async function selectorsElementsCommand(options) {
|
|
9649
10081
|
const projectId = requireAuthAndProject();
|
|
9650
|
-
const
|
|
9651
|
-
const spinner = createSpinner(`Exporting selectors as ${format.toUpperCase()}...`).start();
|
|
10082
|
+
const spinner = createSpinner("Fetching elements...").start();
|
|
9652
10083
|
try {
|
|
9653
|
-
let
|
|
9654
|
-
if (options.
|
|
9655
|
-
const
|
|
9656
|
-
|
|
9657
|
-
if (!feature) {
|
|
9658
|
-
spinner.fail(`Feature "${options.feature}" not found`);
|
|
9659
|
-
process.exit(1);
|
|
9660
|
-
}
|
|
9661
|
-
featureId = feature.id;
|
|
10084
|
+
let pageId;
|
|
10085
|
+
if (options.page) {
|
|
10086
|
+
const page = await findPageByUrl(projectId, options.page);
|
|
10087
|
+
if (page) pageId = page.id;
|
|
9662
10088
|
}
|
|
9663
|
-
const
|
|
9664
|
-
|
|
10089
|
+
const { elements } = await listSelectorElements(projectId, {
|
|
10090
|
+
pageId,
|
|
9665
10091
|
status: options.status,
|
|
9666
|
-
|
|
10092
|
+
tag: options.tag
|
|
9667
10093
|
});
|
|
9668
|
-
|
|
9669
|
-
|
|
9670
|
-
|
|
9671
|
-
|
|
9672
|
-
spinner.succeed(`Selectors exported (${format.toUpperCase()})`);
|
|
10094
|
+
spinner.succeed(`Found ${elements.length} element${elements.length === 1 ? "" : "s"}`);
|
|
10095
|
+
log.newline();
|
|
10096
|
+
if (elements.length === 0) {
|
|
10097
|
+
log.plain("No elements found matching the criteria.");
|
|
9673
10098
|
log.newline();
|
|
9674
|
-
|
|
10099
|
+
return;
|
|
10100
|
+
}
|
|
10101
|
+
const maxNameLen = Math.max(4, ...elements.map((e) => e.name.length));
|
|
10102
|
+
console.log(chalk25.bold("Elements:"));
|
|
10103
|
+
console.log(
|
|
10104
|
+
` ${chalk25.dim("NAME".padEnd(maxNameLen))} ${chalk25.dim("PAGE".padEnd(20))} ${chalk25.dim("STATUS".padEnd(10))} ${chalk25.dim("STRATEGIES")} ${chalk25.dim("ROLE")}`
|
|
10105
|
+
);
|
|
10106
|
+
for (const el of elements) {
|
|
10107
|
+
const name = el.name.padEnd(maxNameLen);
|
|
10108
|
+
const pageName = (el.section?.page?.urlPattern || "").padEnd(20);
|
|
10109
|
+
const status = el.status.padEnd(10);
|
|
10110
|
+
const stratCount = String(el.strategies.length).padStart(10);
|
|
10111
|
+
const role = el.elementRole || "-";
|
|
10112
|
+
const statusColor = el.status === "active" ? chalk25.green(status) : el.status === "broken" ? chalk25.red(status) : chalk25.yellow(status);
|
|
10113
|
+
console.log(
|
|
10114
|
+
` ${name} ${chalk25.dim(pageName)} ${statusColor} ${stratCount} ${chalk25.dim(role)}`
|
|
10115
|
+
);
|
|
10116
|
+
}
|
|
10117
|
+
log.newline();
|
|
10118
|
+
} catch (error) {
|
|
10119
|
+
spinner.fail("Failed to fetch elements");
|
|
10120
|
+
handleCliError(error);
|
|
10121
|
+
process.exit(1);
|
|
10122
|
+
}
|
|
10123
|
+
}
|
|
10124
|
+
async function selectorsFindCommand(description) {
|
|
10125
|
+
const projectId = requireAuthAndProject();
|
|
10126
|
+
const spinner = createSpinner("Searching...").start();
|
|
10127
|
+
try {
|
|
10128
|
+
const result = await resolveSelector(projectId, {
|
|
10129
|
+
url: "/",
|
|
10130
|
+
elementDescription: description
|
|
10131
|
+
});
|
|
10132
|
+
if (!result.matched) {
|
|
10133
|
+
spinner.warn(`No match found (best score: ${((result.matchScore || 0) * 100).toFixed(0)}%)`);
|
|
10134
|
+
log.newline();
|
|
10135
|
+
if (result.element) {
|
|
10136
|
+
const el2 = result.element;
|
|
10137
|
+
log.dim(`Closest: ${el2.name} (score: ${((result.matchScore || 0) * 100).toFixed(0)}%)`);
|
|
10138
|
+
}
|
|
10139
|
+
log.newline();
|
|
10140
|
+
return;
|
|
10141
|
+
}
|
|
10142
|
+
const el = result.element;
|
|
10143
|
+
spinner.succeed(`MATCH (score: ${((result.matchScore || 0) * 100).toFixed(0)}%): ${el.name}`);
|
|
10144
|
+
log.newline();
|
|
10145
|
+
if (result.strategies && result.strategies.length > 0) {
|
|
10146
|
+
console.log(chalk25.bold(" Strategies:"));
|
|
10147
|
+
for (const s of result.strategies) {
|
|
10148
|
+
const successInfo = `rank=${s.rank}, confidence=${(s.confidence * 100).toFixed(0)}%`;
|
|
10149
|
+
console.log(
|
|
10150
|
+
` [${s.rank}] ${s.type}="${s.value}" ${chalk25.dim(`(${successInfo})`)}`
|
|
10151
|
+
);
|
|
10152
|
+
}
|
|
9675
10153
|
}
|
|
9676
10154
|
log.newline();
|
|
9677
10155
|
} catch (error) {
|
|
9678
|
-
spinner.fail("
|
|
10156
|
+
spinner.fail("Search failed");
|
|
9679
10157
|
handleCliError(error);
|
|
9680
10158
|
process.exit(1);
|
|
9681
10159
|
}
|
|
9682
10160
|
}
|
|
10161
|
+
async function selectorsAddPageCommand(options) {
|
|
10162
|
+
const projectId = requireAuthAndProject();
|
|
10163
|
+
if (!options.name || !options.urlPattern) {
|
|
10164
|
+
log.error("Both --name and --url-pattern are required.");
|
|
10165
|
+
process.exit(1);
|
|
10166
|
+
}
|
|
10167
|
+
const spinner = createSpinner("Creating page...").start();
|
|
10168
|
+
try {
|
|
10169
|
+
const tags = options.tags ? options.tags.split(",").map((t) => t.trim()) : void 0;
|
|
10170
|
+
const { page } = await createSelectorPage(projectId, {
|
|
10171
|
+
name: options.name,
|
|
10172
|
+
urlPattern: options.urlPattern,
|
|
10173
|
+
tags
|
|
10174
|
+
});
|
|
10175
|
+
spinner.succeed(`Page created: ${page.name} (${page.urlPattern})`);
|
|
10176
|
+
log.newline();
|
|
10177
|
+
} catch (error) {
|
|
10178
|
+
spinner.fail("Failed to create page");
|
|
10179
|
+
handleCliError(error);
|
|
10180
|
+
process.exit(1);
|
|
10181
|
+
}
|
|
10182
|
+
}
|
|
10183
|
+
async function selectorsAddSectionCommand(options) {
|
|
10184
|
+
const projectId = requireAuthAndProject();
|
|
10185
|
+
if (!options.page || !options.name) {
|
|
10186
|
+
log.error("Both --page and --name are required.");
|
|
10187
|
+
process.exit(1);
|
|
10188
|
+
}
|
|
10189
|
+
const spinner = createSpinner("Creating section...").start();
|
|
10190
|
+
try {
|
|
10191
|
+
const page = await findPageByUrl(projectId, options.page);
|
|
10192
|
+
if (!page) {
|
|
10193
|
+
spinner.fail(`Page with URL pattern "${options.page}" not found`);
|
|
10194
|
+
return;
|
|
10195
|
+
}
|
|
10196
|
+
await createSelectorSection(projectId, page.id, {
|
|
10197
|
+
name: options.name
|
|
10198
|
+
});
|
|
10199
|
+
spinner.succeed(`Section created: ${options.name} (in ${page.name})`);
|
|
10200
|
+
log.newline();
|
|
10201
|
+
} catch (error) {
|
|
10202
|
+
spinner.fail("Failed to create section");
|
|
10203
|
+
handleCliError(error);
|
|
10204
|
+
process.exit(1);
|
|
10205
|
+
}
|
|
10206
|
+
}
|
|
10207
|
+
function parseStrategy(strategyStr) {
|
|
10208
|
+
const match = strategyStr.match(/^(\w+)=(.+)$/);
|
|
10209
|
+
if (!match) return null;
|
|
10210
|
+
const type = match[1];
|
|
10211
|
+
const value = match[2];
|
|
10212
|
+
let playwrightLocator;
|
|
10213
|
+
switch (type) {
|
|
10214
|
+
case "label":
|
|
10215
|
+
playwrightLocator = `getByLabel('${value}')`;
|
|
10216
|
+
break;
|
|
10217
|
+
case "testid":
|
|
10218
|
+
playwrightLocator = `getByTestId('${value}')`;
|
|
10219
|
+
break;
|
|
10220
|
+
case "role":
|
|
10221
|
+
playwrightLocator = value.includes(":") ? `getByRole('${value.split(":")[0]}', { name: '${value.split(":")[1]}' })` : `getByRole('${value}')`;
|
|
10222
|
+
break;
|
|
10223
|
+
case "placeholder":
|
|
10224
|
+
playwrightLocator = `getByPlaceholder('${value}')`;
|
|
10225
|
+
break;
|
|
10226
|
+
case "text":
|
|
10227
|
+
playwrightLocator = `getByText('${value}')`;
|
|
10228
|
+
break;
|
|
10229
|
+
case "css":
|
|
10230
|
+
playwrightLocator = `locator('${value}')`;
|
|
10231
|
+
break;
|
|
10232
|
+
case "id":
|
|
10233
|
+
playwrightLocator = `locator('#${value}')`;
|
|
10234
|
+
break;
|
|
10235
|
+
default:
|
|
10236
|
+
playwrightLocator = `locator('${value}')`;
|
|
10237
|
+
}
|
|
10238
|
+
return { type, value, playwrightLocator };
|
|
10239
|
+
}
|
|
10240
|
+
async function selectorsAddElementCommand(options) {
|
|
10241
|
+
const projectId = requireAuthAndProject();
|
|
10242
|
+
if (!options.page || !options.section || !options.name) {
|
|
10243
|
+
log.error("--page, --section, and --name are required.");
|
|
10244
|
+
process.exit(1);
|
|
10245
|
+
}
|
|
10246
|
+
const spinner = createSpinner("Creating element...").start();
|
|
10247
|
+
try {
|
|
10248
|
+
const page = await findPageByUrl(projectId, options.page);
|
|
10249
|
+
if (!page) {
|
|
10250
|
+
spinner.fail(`Page with URL pattern "${options.page}" not found`);
|
|
10251
|
+
return;
|
|
10252
|
+
}
|
|
10253
|
+
let sectionId;
|
|
10254
|
+
const { section } = await createSelectorSection(projectId, page.id, {
|
|
10255
|
+
name: options.section
|
|
10256
|
+
}).catch(async () => {
|
|
10257
|
+
const { elements: elems } = await listSelectorElements(projectId, { pageId: page.id });
|
|
10258
|
+
throw new Error(`Section "${options.section}" could not be created or found.`);
|
|
10259
|
+
});
|
|
10260
|
+
sectionId = section.id;
|
|
10261
|
+
const strategies = [];
|
|
10262
|
+
const strategyInputs = Array.isArray(options.strategy) ? options.strategy : options.strategy ? [options.strategy] : [];
|
|
10263
|
+
for (const s of strategyInputs) {
|
|
10264
|
+
const parsed = parseStrategy(s);
|
|
10265
|
+
if (parsed) {
|
|
10266
|
+
strategies.push({ ...parsed, confidence: 0.8, source: "manual" });
|
|
10267
|
+
}
|
|
10268
|
+
}
|
|
10269
|
+
const { element } = await createSelectorElement(projectId, {
|
|
10270
|
+
sectionId,
|
|
10271
|
+
name: options.name,
|
|
10272
|
+
elementRole: options.role,
|
|
10273
|
+
strategies: strategies.length > 0 ? strategies : void 0
|
|
10274
|
+
});
|
|
10275
|
+
spinner.succeed(`Element created: ${element.name} (${element.strategies.length} strategies)`);
|
|
10276
|
+
log.newline();
|
|
10277
|
+
} catch (error) {
|
|
10278
|
+
spinner.fail("Failed to create element");
|
|
10279
|
+
handleCliError(error);
|
|
10280
|
+
process.exit(1);
|
|
10281
|
+
}
|
|
10282
|
+
}
|
|
10283
|
+
async function selectorsHistoryCommand(options) {
|
|
10284
|
+
const projectId = requireAuthAndProject();
|
|
10285
|
+
if (!options.element) {
|
|
10286
|
+
log.error("--element is required.");
|
|
10287
|
+
process.exit(1);
|
|
10288
|
+
}
|
|
10289
|
+
const spinner = createSpinner("Fetching history...").start();
|
|
10290
|
+
try {
|
|
10291
|
+
const element = await findElementByName(projectId, options.element, options.page);
|
|
10292
|
+
if (!element) {
|
|
10293
|
+
spinner.fail(`Element "${options.element}" not found`);
|
|
10294
|
+
return;
|
|
10295
|
+
}
|
|
10296
|
+
const { history, total } = await getSelectorElementHistory(
|
|
10297
|
+
projectId,
|
|
10298
|
+
element.id,
|
|
10299
|
+
20
|
|
10300
|
+
);
|
|
10301
|
+
spinner.succeed(`History for "${element.name}" (${total} entries)`);
|
|
10302
|
+
log.newline();
|
|
10303
|
+
if (history.length === 0) {
|
|
10304
|
+
log.plain("No history entries found.");
|
|
10305
|
+
log.newline();
|
|
10306
|
+
return;
|
|
10307
|
+
}
|
|
10308
|
+
console.log(chalk25.bold("History:"));
|
|
10309
|
+
for (let i = 0; i < history.length; i++) {
|
|
10310
|
+
const entry = history[i];
|
|
10311
|
+
const date = new Date(entry.createdAt).toLocaleString();
|
|
10312
|
+
const actionLabel = entry.action.replace(/_/g, " ");
|
|
10313
|
+
const changedBy = entry.changedBy || "unknown";
|
|
10314
|
+
const reason = entry.reason ? ` - ${entry.reason}` : "";
|
|
10315
|
+
console.log(
|
|
10316
|
+
` ${chalk25.dim(`#${total - i}`)} ${chalk25.bold(actionLabel)} ${chalk25.dim(date)} by ${chalk25.cyan(changedBy)}${chalk25.dim(reason)}`
|
|
10317
|
+
);
|
|
10318
|
+
console.log(` ${chalk25.dim(`ID: ${entry.id}`)}`);
|
|
10319
|
+
}
|
|
10320
|
+
log.newline();
|
|
10321
|
+
} catch (error) {
|
|
10322
|
+
spinner.fail("Failed to fetch history");
|
|
10323
|
+
handleCliError(error);
|
|
10324
|
+
process.exit(1);
|
|
10325
|
+
}
|
|
10326
|
+
}
|
|
10327
|
+
async function selectorsRollbackCommand(options) {
|
|
10328
|
+
const projectId = requireAuthAndProject();
|
|
10329
|
+
if (!options.element || !options.version) {
|
|
10330
|
+
log.error("--element and --version are required.");
|
|
10331
|
+
process.exit(1);
|
|
10332
|
+
}
|
|
10333
|
+
const spinner = createSpinner("Rolling back...").start();
|
|
10334
|
+
try {
|
|
10335
|
+
const element = await findElementByName(projectId, options.element, options.page);
|
|
10336
|
+
if (!element) {
|
|
10337
|
+
spinner.fail(`Element "${options.element}" not found`);
|
|
10338
|
+
return;
|
|
10339
|
+
}
|
|
10340
|
+
const { history, total } = await getSelectorElementHistory(
|
|
10341
|
+
projectId,
|
|
10342
|
+
element.id,
|
|
10343
|
+
200
|
|
10344
|
+
// fetch enough to find the version
|
|
10345
|
+
);
|
|
10346
|
+
const versionNum = parseInt(options.version, 10);
|
|
10347
|
+
const entryIndex = versionNum - 1;
|
|
10348
|
+
if (entryIndex < 0 || entryIndex >= history.length) {
|
|
10349
|
+
spinner.fail(`Version ${versionNum} not found. Available: 1-${history.length}`);
|
|
10350
|
+
return;
|
|
10351
|
+
}
|
|
10352
|
+
const historyEntry = history[entryIndex];
|
|
10353
|
+
await rollbackSelectorElement(projectId, element.id, historyEntry.id);
|
|
10354
|
+
spinner.succeed(`Rolled back "${element.name}" to version ${versionNum}`);
|
|
10355
|
+
log.newline();
|
|
10356
|
+
} catch (error) {
|
|
10357
|
+
spinner.fail("Rollback failed");
|
|
10358
|
+
handleCliError(error);
|
|
10359
|
+
process.exit(1);
|
|
10360
|
+
}
|
|
10361
|
+
}
|
|
10362
|
+
async function selectorsHealthCommand() {
|
|
10363
|
+
const projectId = requireAuthAndProject();
|
|
10364
|
+
const spinner = createSpinner("Checking selector health...").start();
|
|
10365
|
+
try {
|
|
10366
|
+
const { elements } = await listSelectorElements(projectId);
|
|
10367
|
+
const total = elements.length;
|
|
10368
|
+
const active = elements.filter((e) => e.status === "active").length;
|
|
10369
|
+
const broken = elements.filter((e) => e.status === "broken").length;
|
|
10370
|
+
const deprecated = elements.filter((e) => e.status === "deprecated").length;
|
|
10371
|
+
const now = Date.now();
|
|
10372
|
+
const sevenDaysMs = 7 * 24 * 60 * 60 * 1e3;
|
|
10373
|
+
const stale = elements.filter((e) => {
|
|
10374
|
+
if (e.status === "deprecated") return false;
|
|
10375
|
+
if (e.strategies.length === 0) return true;
|
|
10376
|
+
return e.strategies.every((s) => {
|
|
10377
|
+
if (!s.lastUsedAt) return true;
|
|
10378
|
+
return now - new Date(s.lastUsedAt).getTime() > sevenDaysMs;
|
|
10379
|
+
});
|
|
10380
|
+
});
|
|
10381
|
+
spinner.succeed("Selector Library Health");
|
|
10382
|
+
log.newline();
|
|
10383
|
+
console.log(` ${chalk25.bold("Total Elements:")} ${total}`);
|
|
10384
|
+
console.log(
|
|
10385
|
+
` ${chalk25.green("Active:")} ${active} | ${chalk25.yellow("Stale:")} ${stale.length} | ${chalk25.red("Broken:")} ${broken} | ${chalk25.dim("Deprecated:")} ${deprecated}`
|
|
10386
|
+
);
|
|
10387
|
+
log.newline();
|
|
10388
|
+
if (broken > 0) {
|
|
10389
|
+
console.log(chalk25.bold.red(" BROKEN ELEMENTS:"));
|
|
10390
|
+
const brokenElements = elements.filter((e) => e.status === "broken");
|
|
10391
|
+
for (const el of brokenElements) {
|
|
10392
|
+
const totalSuccess = el.strategies.reduce((sum, s) => sum + s.successCount, 0);
|
|
10393
|
+
const totalFail = el.strategies.reduce((sum, s) => sum + s.failCount, 0);
|
|
10394
|
+
const lastFailed = el.strategies.map((s) => s.lastFailedAt).filter(Boolean).sort().reverse()[0];
|
|
10395
|
+
const failedAgo = lastFailed ? formatTimeAgo(new Date(lastFailed)) : "never";
|
|
10396
|
+
const pageName = el.section?.page?.name || "Unknown";
|
|
10397
|
+
console.log(
|
|
10398
|
+
` ${icons.fail} ${pageName} > ${el.name} ${chalk25.dim(`(${totalSuccess}/${totalSuccess + totalFail} success, last failed ${failedAgo})`)}`
|
|
10399
|
+
);
|
|
10400
|
+
}
|
|
10401
|
+
log.newline();
|
|
10402
|
+
}
|
|
10403
|
+
if (stale.length > 0) {
|
|
10404
|
+
console.log(chalk25.bold.yellow(" STALE ELEMENTS:"));
|
|
10405
|
+
for (const el of stale.slice(0, 10)) {
|
|
10406
|
+
const lastUsed = el.strategies.map((s) => s.lastUsedAt).filter(Boolean).sort().reverse()[0];
|
|
10407
|
+
const usedAgo = lastUsed ? formatTimeAgo(new Date(lastUsed)) : "never used";
|
|
10408
|
+
const pageName = el.section?.page?.name || "Unknown";
|
|
10409
|
+
console.log(
|
|
10410
|
+
` ${icons.warning} ${pageName} > ${el.name} ${chalk25.dim(`(${usedAgo})`)}`
|
|
10411
|
+
);
|
|
10412
|
+
}
|
|
10413
|
+
if (stale.length > 10) {
|
|
10414
|
+
log.dim(` ... and ${stale.length - 10} more`);
|
|
10415
|
+
}
|
|
10416
|
+
log.newline();
|
|
10417
|
+
}
|
|
10418
|
+
} catch (error) {
|
|
10419
|
+
spinner.fail("Failed to check health");
|
|
10420
|
+
handleCliError(error);
|
|
10421
|
+
process.exit(1);
|
|
10422
|
+
}
|
|
10423
|
+
}
|
|
10424
|
+
async function selectorsExportCommand(options) {
|
|
10425
|
+
const projectId = requireAuthAndProject();
|
|
10426
|
+
if (!options.output) {
|
|
10427
|
+
log.error("--output is required.");
|
|
10428
|
+
process.exit(1);
|
|
10429
|
+
}
|
|
10430
|
+
const spinner = createSpinner("Exporting selector library...").start();
|
|
10431
|
+
try {
|
|
10432
|
+
const exportData = await exportSelectorLibrary(projectId);
|
|
10433
|
+
if (options.page) {
|
|
10434
|
+
exportData.pages = exportData.pages.filter(
|
|
10435
|
+
(p) => p.urlPattern === options.page || p.urlPattern === `/${options.page.replace(/^\//, "")}`
|
|
10436
|
+
);
|
|
10437
|
+
}
|
|
10438
|
+
const json = JSON.stringify(exportData, null, 2);
|
|
10439
|
+
fs9.writeFileSync(options.output, json, "utf-8");
|
|
10440
|
+
const pageCount = exportData.pages.length;
|
|
10441
|
+
const elementCount = exportData.pages.reduce(
|
|
10442
|
+
(sum, p) => sum + p.sections.reduce(
|
|
10443
|
+
(sSum, s) => sSum + s.elements.length,
|
|
10444
|
+
0
|
|
10445
|
+
),
|
|
10446
|
+
0
|
|
10447
|
+
);
|
|
10448
|
+
spinner.succeed(
|
|
10449
|
+
`Exported ${pageCount} page${pageCount === 1 ? "" : "s"}, ${elementCount} element${elementCount === 1 ? "" : "s"} to ${options.output}`
|
|
10450
|
+
);
|
|
10451
|
+
log.newline();
|
|
10452
|
+
} catch (error) {
|
|
10453
|
+
spinner.fail("Export failed");
|
|
10454
|
+
handleCliError(error);
|
|
10455
|
+
process.exit(1);
|
|
10456
|
+
}
|
|
10457
|
+
}
|
|
10458
|
+
async function selectorsImportCommand(options) {
|
|
10459
|
+
const projectId = requireAuthAndProject();
|
|
10460
|
+
if (!options.input) {
|
|
10461
|
+
log.error("--input is required.");
|
|
10462
|
+
process.exit(1);
|
|
10463
|
+
}
|
|
10464
|
+
if (!fs9.existsSync(options.input)) {
|
|
10465
|
+
log.error(`File not found: ${options.input}`);
|
|
10466
|
+
process.exit(1);
|
|
10467
|
+
}
|
|
10468
|
+
const spinner = createSpinner("Reading import file...").start();
|
|
10469
|
+
try {
|
|
10470
|
+
const raw = fs9.readFileSync(options.input, "utf-8");
|
|
10471
|
+
const data = JSON.parse(raw);
|
|
10472
|
+
if (!data.version || !Array.isArray(data.pages)) {
|
|
10473
|
+
spinner.fail("Invalid import file format");
|
|
10474
|
+
return;
|
|
10475
|
+
}
|
|
10476
|
+
const pageCount = data.pages.length;
|
|
10477
|
+
const elementCount = data.pages.reduce(
|
|
10478
|
+
(sum, p) => sum + p.sections.reduce(
|
|
10479
|
+
(sSum, s) => sSum + s.elements.length,
|
|
10480
|
+
0
|
|
10481
|
+
),
|
|
10482
|
+
0
|
|
10483
|
+
);
|
|
10484
|
+
const strategyCount = data.pages.reduce(
|
|
10485
|
+
(sum, p) => sum + p.sections.reduce(
|
|
10486
|
+
(sSum, s) => sSum + s.elements.reduce(
|
|
10487
|
+
(eSum, e) => eSum + e.strategies.length,
|
|
10488
|
+
0
|
|
10489
|
+
),
|
|
10490
|
+
0
|
|
10491
|
+
),
|
|
10492
|
+
0
|
|
10493
|
+
);
|
|
10494
|
+
if (options.dryRun) {
|
|
10495
|
+
spinner.succeed("Dry run - no changes will be made");
|
|
10496
|
+
log.newline();
|
|
10497
|
+
console.log(chalk25.bold("Import preview:"));
|
|
10498
|
+
console.log(` Pages: ${pageCount}`);
|
|
10499
|
+
console.log(` Elements: ${elementCount}`);
|
|
10500
|
+
console.log(` Strategies: ${strategyCount}`);
|
|
10501
|
+
log.newline();
|
|
10502
|
+
for (const page of data.pages) {
|
|
10503
|
+
console.log(` ${chalk25.bold(page.name)} (${page.urlPattern})`);
|
|
10504
|
+
for (const section of page.sections) {
|
|
10505
|
+
console.log(` ${section.name}`);
|
|
10506
|
+
for (const element of section.elements) {
|
|
10507
|
+
console.log(` ${element.name} (${element.strategies.length} strategies)`);
|
|
10508
|
+
}
|
|
10509
|
+
}
|
|
10510
|
+
}
|
|
10511
|
+
log.newline();
|
|
10512
|
+
return;
|
|
10513
|
+
}
|
|
10514
|
+
spinner.text("Importing...");
|
|
10515
|
+
const result = await importSelectorLibrary(projectId, data);
|
|
10516
|
+
spinner.succeed("Import complete");
|
|
10517
|
+
log.newline();
|
|
10518
|
+
console.log(chalk25.bold("Results:"));
|
|
10519
|
+
console.log(` Pages created: ${result.imported.pages}`);
|
|
10520
|
+
console.log(` Sections created: ${result.imported.sections}`);
|
|
10521
|
+
console.log(` Elements created: ${result.imported.elements}`);
|
|
10522
|
+
console.log(` Strategies created: ${result.imported.strategies}`);
|
|
10523
|
+
console.log(` Skipped (duplicates): ${result.skipped}`);
|
|
10524
|
+
log.newline();
|
|
10525
|
+
} catch (error) {
|
|
10526
|
+
spinner.fail("Import failed");
|
|
10527
|
+
handleCliError(error);
|
|
10528
|
+
process.exit(1);
|
|
10529
|
+
}
|
|
10530
|
+
}
|
|
10531
|
+
async function selectorsListCommand(options) {
|
|
10532
|
+
return selectorsPagesCommand({});
|
|
10533
|
+
}
|
|
10534
|
+
function formatTimeAgo(date) {
|
|
10535
|
+
const now = Date.now();
|
|
10536
|
+
const diff = now - date.getTime();
|
|
10537
|
+
const minutes = Math.floor(diff / 6e4);
|
|
10538
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
10539
|
+
const hours = Math.floor(minutes / 60);
|
|
10540
|
+
if (hours < 24) return `${hours}h ago`;
|
|
10541
|
+
const days = Math.floor(hours / 24);
|
|
10542
|
+
return `${days}d ago`;
|
|
10543
|
+
}
|
|
9683
10544
|
|
|
9684
10545
|
// src/commands/demo.ts
|
|
9685
10546
|
import chalk26 from "chalk";
|
|
@@ -9966,7 +10827,7 @@ var package_default = {
|
|
|
9966
10827
|
publishConfig: {
|
|
9967
10828
|
access: "public"
|
|
9968
10829
|
},
|
|
9969
|
-
version: "0.1.
|
|
10830
|
+
version: "0.1.4",
|
|
9970
10831
|
description: "TestBro CLI - AI-powered browser testing from your terminal",
|
|
9971
10832
|
type: "module",
|
|
9972
10833
|
bin: {
|
|
@@ -10294,7 +11155,7 @@ program.command("generate").description("Generate test cases from a file or desc
|
|
|
10294
11155
|
process.exit(1);
|
|
10295
11156
|
}
|
|
10296
11157
|
});
|
|
10297
|
-
program.command("run").description("Run tests against a URL").option("-u, --url <url>", "Target URL to test (or use baseUrl from test-bro.config.json)").option("--file <path>", "Path to file (AC/PRD) to generate and run tests from").option("-d, --description <text>", "Feature description to test").option("-p, --project <id>", "Project ID to use test cases from (overrides active)").option("-t, --test-case <id>", "Run specific test case only").option("-m, --mode <mode>", "Execution mode: selector (default), ai, or hybrid", "selector").option("--headed", "Run browser in headed mode (visible)", false).option("--remote", "Run tests on remote staging environment", false).option("-f, --format <format>", "Output format: pretty or json", "pretty").option("-v, --verbose", "Show verbose output", false).option("--timeout <ms>", "Timeout per step in milliseconds", "30000").option("--learn-selectors", "Learn selectors from AI actions", false).option("--yes", "Auto-confirm selector learning", false).option("--no-dedup", "Skip deduplication check when using --file").option("--auto-merge", "Auto-apply smart merge without prompting when using --file").option("--record-video", "Record video of the test execution", false).option("--record-trace", "Capture Playwright trace for debugging", false).option("-e, --env <name>", "Use a named environment (fetches baseUrl from API)").option("--credential <name>", "Use a named credential from the vault").option("--no-sequential", "Run test cases independently (re-navigate for each)").action(async (options) => {
|
|
11158
|
+
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("--feature <id|name>", "Associate this run with a feature (by ID or name)").option("--no-sequential", "Run test cases independently (re-navigate for each)").action(async (options) => {
|
|
10298
11159
|
if (options.url && !validateUrl(options.url)) {
|
|
10299
11160
|
displayError("Invalid URL format", `Provided URL: ${options.url}`);
|
|
10300
11161
|
console.log(chalk29.dim(' Example: testbro run --url https://example.com --description "..."'));
|
|
@@ -10341,6 +11202,7 @@ program.command("run").description("Run tests against a URL").option("-u, --url
|
|
|
10341
11202
|
recordTrace: options.recordTrace,
|
|
10342
11203
|
env: options.env,
|
|
10343
11204
|
credential: options.credential,
|
|
11205
|
+
feature: options.feature,
|
|
10344
11206
|
sequential: options.sequential
|
|
10345
11207
|
});
|
|
10346
11208
|
} catch (error) {
|
|
@@ -10430,8 +11292,8 @@ featuresCmd.command("assign").description("Assign a test case to a feature by sl
|
|
|
10430
11292
|
process.exit(1);
|
|
10431
11293
|
}
|
|
10432
11294
|
});
|
|
10433
|
-
var selectorsCmd = program.command("selectors").description("
|
|
10434
|
-
selectorsCmd.option("-
|
|
11295
|
+
var selectorsCmd = program.command("selectors").description("Browse and manage the selector library");
|
|
11296
|
+
selectorsCmd.option("-v, --verbose", "Show sections and elements").action(async (options) => {
|
|
10435
11297
|
try {
|
|
10436
11298
|
await selectorsListCommand(options);
|
|
10437
11299
|
} catch (error) {
|
|
@@ -10442,9 +11304,31 @@ selectorsCmd.option("-f, --feature <slug>", "Filter by feature slug").option("-s
|
|
|
10442
11304
|
process.exit(1);
|
|
10443
11305
|
}
|
|
10444
11306
|
});
|
|
10445
|
-
selectorsCmd.command("
|
|
11307
|
+
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) => {
|
|
11308
|
+
try {
|
|
11309
|
+
await selectorsPagesCommand(options);
|
|
11310
|
+
} catch (error) {
|
|
11311
|
+
if (error instanceof Error) {
|
|
11312
|
+
displayError(error.message);
|
|
11313
|
+
}
|
|
11314
|
+
printErrorHints(error);
|
|
11315
|
+
process.exit(1);
|
|
11316
|
+
}
|
|
11317
|
+
});
|
|
11318
|
+
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) => {
|
|
11319
|
+
try {
|
|
11320
|
+
await selectorsElementsCommand(options);
|
|
11321
|
+
} catch (error) {
|
|
11322
|
+
if (error instanceof Error) {
|
|
11323
|
+
displayError(error.message);
|
|
11324
|
+
}
|
|
11325
|
+
printErrorHints(error);
|
|
11326
|
+
process.exit(1);
|
|
11327
|
+
}
|
|
11328
|
+
});
|
|
11329
|
+
selectorsCmd.command("find <description>").description("Fuzzy search for an element by description").action(async (description) => {
|
|
10446
11330
|
try {
|
|
10447
|
-
await
|
|
11331
|
+
await selectorsFindCommand(description);
|
|
10448
11332
|
} catch (error) {
|
|
10449
11333
|
if (error instanceof Error) {
|
|
10450
11334
|
displayError(error.message);
|
|
@@ -10453,7 +11337,73 @@ selectorsCmd.command("verify").description("Bulk re-verify selectors (refreshes
|
|
|
10453
11337
|
process.exit(1);
|
|
10454
11338
|
}
|
|
10455
11339
|
});
|
|
10456
|
-
selectorsCmd.command("
|
|
11340
|
+
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) => {
|
|
11341
|
+
try {
|
|
11342
|
+
await selectorsAddPageCommand(options);
|
|
11343
|
+
} catch (error) {
|
|
11344
|
+
if (error instanceof Error) {
|
|
11345
|
+
displayError(error.message);
|
|
11346
|
+
}
|
|
11347
|
+
printErrorHints(error);
|
|
11348
|
+
process.exit(1);
|
|
11349
|
+
}
|
|
11350
|
+
});
|
|
11351
|
+
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) => {
|
|
11352
|
+
try {
|
|
11353
|
+
await selectorsAddSectionCommand(options);
|
|
11354
|
+
} catch (error) {
|
|
11355
|
+
if (error instanceof Error) {
|
|
11356
|
+
displayError(error.message);
|
|
11357
|
+
}
|
|
11358
|
+
printErrorHints(error);
|
|
11359
|
+
process.exit(1);
|
|
11360
|
+
}
|
|
11361
|
+
});
|
|
11362
|
+
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) => {
|
|
11363
|
+
try {
|
|
11364
|
+
await selectorsAddElementCommand(options);
|
|
11365
|
+
} catch (error) {
|
|
11366
|
+
if (error instanceof Error) {
|
|
11367
|
+
displayError(error.message);
|
|
11368
|
+
}
|
|
11369
|
+
printErrorHints(error);
|
|
11370
|
+
process.exit(1);
|
|
11371
|
+
}
|
|
11372
|
+
});
|
|
11373
|
+
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) => {
|
|
11374
|
+
try {
|
|
11375
|
+
await selectorsHistoryCommand(options);
|
|
11376
|
+
} catch (error) {
|
|
11377
|
+
if (error instanceof Error) {
|
|
11378
|
+
displayError(error.message);
|
|
11379
|
+
}
|
|
11380
|
+
printErrorHints(error);
|
|
11381
|
+
process.exit(1);
|
|
11382
|
+
}
|
|
11383
|
+
});
|
|
11384
|
+
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) => {
|
|
11385
|
+
try {
|
|
11386
|
+
await selectorsRollbackCommand(options);
|
|
11387
|
+
} catch (error) {
|
|
11388
|
+
if (error instanceof Error) {
|
|
11389
|
+
displayError(error.message);
|
|
11390
|
+
}
|
|
11391
|
+
printErrorHints(error);
|
|
11392
|
+
process.exit(1);
|
|
11393
|
+
}
|
|
11394
|
+
});
|
|
11395
|
+
selectorsCmd.command("health").description("Show health summary of the selector library").action(async () => {
|
|
11396
|
+
try {
|
|
11397
|
+
await selectorsHealthCommand();
|
|
11398
|
+
} catch (error) {
|
|
11399
|
+
if (error instanceof Error) {
|
|
11400
|
+
displayError(error.message);
|
|
11401
|
+
}
|
|
11402
|
+
printErrorHints(error);
|
|
11403
|
+
process.exit(1);
|
|
11404
|
+
}
|
|
11405
|
+
});
|
|
11406
|
+
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) => {
|
|
10457
11407
|
try {
|
|
10458
11408
|
await selectorsExportCommand(options);
|
|
10459
11409
|
} catch (error) {
|
|
@@ -10464,6 +11414,17 @@ selectorsCmd.command("export").description("Export selectors as JSON or CSV").op
|
|
|
10464
11414
|
process.exit(1);
|
|
10465
11415
|
}
|
|
10466
11416
|
});
|
|
11417
|
+
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) => {
|
|
11418
|
+
try {
|
|
11419
|
+
await selectorsImportCommand(options);
|
|
11420
|
+
} catch (error) {
|
|
11421
|
+
if (error instanceof Error) {
|
|
11422
|
+
displayError(error.message);
|
|
11423
|
+
}
|
|
11424
|
+
printErrorHints(error);
|
|
11425
|
+
process.exit(1);
|
|
11426
|
+
}
|
|
11427
|
+
});
|
|
10467
11428
|
var demoCmd = program.command("demo").description("Record and manage demo runs");
|
|
10468
11429
|
demoCmd.action(async () => {
|
|
10469
11430
|
try {
|
|
@@ -10610,6 +11571,24 @@ function displayConfig() {
|
|
|
10610
11571
|
const autoMerge = pc.deduplication.autoMerge === true;
|
|
10611
11572
|
console.log(` ${chalk29.dim("Deduplication:")} ${dedupEnabled ? chalk29.green("enabled") : chalk29.yellow("disabled")}${autoMerge ? chalk29.dim(" (auto-merge)") : ""}`);
|
|
10612
11573
|
}
|
|
11574
|
+
if (pc.environments && Object.keys(pc.environments).length > 0) {
|
|
11575
|
+
const envNames = Object.keys(pc.environments);
|
|
11576
|
+
const activeEnv = pc.environment || "local";
|
|
11577
|
+
console.log(` ${chalk29.dim("Environments:")} ${envNames.map((name) => name === activeEnv ? chalk29.green(name + " *") : name).join(", ")}`);
|
|
11578
|
+
const activeEntry = pc.environments[activeEnv];
|
|
11579
|
+
if (activeEntry) {
|
|
11580
|
+
console.log(` ${chalk29.dim("Active env URL:")} ${activeEntry.baseUrl}`);
|
|
11581
|
+
if (activeEntry.apiUrl) {
|
|
11582
|
+
console.log(` ${chalk29.dim("Active env API:")} ${activeEntry.apiUrl}`);
|
|
11583
|
+
}
|
|
11584
|
+
if (activeEntry.credentials) {
|
|
11585
|
+
console.log(` ${chalk29.dim("Active env credentials:")} ${Object.keys(activeEntry.credentials).join(", ")}`);
|
|
11586
|
+
}
|
|
11587
|
+
if (activeEntry.auth) {
|
|
11588
|
+
console.log(` ${chalk29.dim("Active env auth:")} ${chalk29.green("configured")} ${chalk29.dim(`(${activeEntry.auth.loginUrl})`)}`);
|
|
11589
|
+
}
|
|
11590
|
+
}
|
|
11591
|
+
}
|
|
10613
11592
|
}
|
|
10614
11593
|
} else {
|
|
10615
11594
|
console.log(` ${chalk29.dim("Not found")}`);
|