@test-bro/cli 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1172 -263
  2. 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, localEnvironmentSchema, deduplicationConfigSchema, authStepSchema, authConfigSchema, projectConfigSchema, CONFIG_FILE_NAMES, CONFIG_TEMPLATE;
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.array(localEnvironmentSchema).optional(),
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 listSelectors(projectId, options) {
908
+ async function listSelectorPages(projectId, options) {
892
909
  const params = new URLSearchParams();
893
- if (options?.featureSlug) {
894
- const { features } = await listFeatures(projectId);
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}/selectors${query ? `?${query}` : ""}`
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 bulkVerifySelectors(projectId, selectorIds) {
926
+ async function learnSelectors(projectId, pageUrl, learnedElements) {
911
927
  return apiRequest(
912
- `/api/projects/${projectId}/selectors/verify`,
928
+ `/api/projects/${projectId}/selector-learn`,
913
929
  {
914
930
  method: "POST",
915
- body: JSON.stringify(selectorIds?.length ? { selectorIds } : {})
931
+ body: JSON.stringify({ pageUrl, learnedElements })
916
932
  }
917
933
  );
918
934
  }
919
- async function exportSelectors(projectId, options) {
935
+ async function listSelectorElements(projectId, filters) {
920
936
  const params = new URLSearchParams();
921
- params.set("format", options?.format ?? "json");
922
- if (options?.status) params.set("status", options.status);
923
- if (options?.featureId) params.set("featureId", options.featureId);
924
- const format = options?.format ?? "json";
925
- if (format === "csv") {
926
- const apiUrl = getApiUrl();
927
- const token = getToken();
928
- const url = `${apiUrl}/api/projects/${projectId}/selectors/export?${params.toString()}`;
929
- const response = await fetch(url, {
930
- headers: {
931
- "Content-Type": "application/json",
932
- ...token ? { Authorization: `Bearer ${token}` } : {}
933
- }
934
- });
935
- if (!response.ok) {
936
- const data2 = await response.json().catch(() => ({ error: response.statusText }));
937
- throw new ApiError(
938
- data2.error || `Request failed: ${response.statusText}`,
939
- response.status
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) ?? (isAuthenticated2() ? resolveEnvironmentFromCloud(name, projectId) : null);
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) ?? (isAuthenticated2() ? resolveCredentialFromCloud(name, projectId) : null);
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 match = config.environments.find(
1744
- (env) => env.name.toLowerCase() === name.toLowerCase()
1799
+ const key = Object.keys(config.environments).find(
1800
+ (k) => k.toLowerCase() === name.toLowerCase()
1745
1801
  );
1746
- if (!match) return null;
1802
+ if (!key) return null;
1803
+ const entry = config.environments[key];
1747
1804
  return {
1748
- name: match.name,
1749
- baseUrl: match.baseUrl,
1750
- variables: match.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 authEnabled = !!projectConfig?.auth;
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 = authEnabled ? stripLoginSteps(steps) : steps;
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
- remote,
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
- remote,
2937
+ envName,
2869
2938
  projectConfig,
2870
- isPretty
2939
+ isPretty,
2940
+ authEnabled
2871
2941
  );
2872
2942
  }
2873
- return buildFromDescription(description, url, remote, projectId, isPretty);
2943
+ return buildFromDescription(description, url, envName, projectId, isPretty);
2874
2944
  }
2875
- async function buildFromGeneratedTestCases(testCases, file, projectId, url, remote, projectConfig, isPretty) {
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
- remote ? "staging" : "local",
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, remote, projectConfig, isPretty) {
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
- remote ? "staging" : "local",
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, remote, projectId, isPretty) {
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
- remote ? "staging" : "local",
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 hasSelectors = (step.target?.suggestedStrategies?.length ?? 0) > 0;
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 (step.action === "navigate" && step.value) {
6337
- return this.executeNavigate(step);
6411
+ if (enrichedStep.action === "navigate" && enrichedStep.value) {
6412
+ return this.executeNavigate(enrichedStep);
6338
6413
  }
6339
6414
  if (hasSelectors) {
6340
- const selectorResult = await this.trySelector(step);
6415
+ const selectorResult = await this.trySelector(enrichedStep);
6341
6416
  if (selectorResult.success) {
6342
6417
  const result3 = {
6343
- stepId: step.id,
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?.(step.id, selectorResult.error || "Selector failed");
6354
- const aiLog = await this.executeWithAI(step);
6428
+ this.options.onFallback?.(enrichedStep.id, selectorResult.error || "Selector failed");
6429
+ const aiLog = await this.executeWithAI(enrichedStep);
6355
6430
  if (aiLog?.success) {
6356
- let learnedSelector;
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: step.id,
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: step.id,
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: step.id,
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(step);
6407
- let learnedSelector;
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: step.id,
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: step.id,
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,174 @@ 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
+ if (this.page) {
6625
+ const pageUrl = this.page.url();
6626
+ const tagName = aiLog.element.tagName?.toLowerCase();
6627
+ this.learnToLibrary(pageUrl, elementDescription, tagName, strategies).catch(() => {
6628
+ });
6629
+ }
6630
+ return { stepId, elementDescription, strategies };
6631
+ }
6632
+ /**
6633
+ * Call the selector-learn API to persist learned selectors to the library.
6634
+ * Fire-and-forget: failures are logged but do not affect execution.
6635
+ */
6636
+ async learnToLibrary(pageUrl, elementDescription, elementRole, strategies) {
6637
+ const { apiBaseUrl, apiToken, projectId } = this.options;
6638
+ if (!apiBaseUrl || !apiToken || !projectId) return;
6639
+ try {
6640
+ const url = `${apiBaseUrl}/api/projects/${projectId}/selector-learn`;
6641
+ await fetch(url, {
6642
+ method: "POST",
6643
+ headers: {
6644
+ "Content-Type": "application/json",
6645
+ Authorization: `Bearer ${apiToken}`,
6646
+ "x-api-key": apiToken
6647
+ },
6648
+ body: JSON.stringify({
6649
+ pageUrl,
6650
+ learnedElements: [
6651
+ {
6652
+ elementDescription,
6653
+ elementRole,
6654
+ strategies
6655
+ }
6656
+ ]
6657
+ }),
6658
+ signal: AbortSignal.timeout(1e4)
6659
+ });
6660
+ } catch {
6661
+ }
6662
+ }
6663
+ /**
6664
+ * Fetch the full library export and cache it for the duration of plan execution.
6665
+ * Called once before executeTestPlan; failures are non-fatal.
6666
+ */
6667
+ async fetchLibraryIndex() {
6668
+ const { apiBaseUrl, apiToken, projectId } = this.options;
6669
+ if (!apiBaseUrl || !apiToken || !projectId) return;
6670
+ try {
6671
+ const url = `${apiBaseUrl}/api/projects/${projectId}/selector-library/export`;
6672
+ const response = await fetch(url, {
6673
+ method: "GET",
6674
+ headers: {
6675
+ Authorization: `Bearer ${apiToken}`,
6676
+ "x-api-key": apiToken
6677
+ },
6678
+ signal: AbortSignal.timeout(15e3)
6679
+ });
6680
+ if (!response.ok) return;
6681
+ const json = await response.json();
6682
+ const exportData = json.data ?? json;
6683
+ if (!Array.isArray(exportData?.pages)) return;
6684
+ const pages = exportData.pages.map((p) => ({
6685
+ urlPattern: p.urlPattern,
6686
+ elements: (p.sections ?? []).flatMap(
6687
+ (s) => (s.elements ?? []).map((e) => ({
6688
+ name: e.name,
6689
+ aliases: e.aliases ?? [],
6690
+ elementRole: e.elementRole ?? null,
6691
+ strategies: (e.strategies ?? []).map((st) => ({
6692
+ type: st.type,
6693
+ value: st.value,
6694
+ playwrightLocator: st.playwrightLocator,
6695
+ confidence: st.confidence ?? 0.8,
6696
+ rank: st.rank ?? 0
6697
+ }))
6698
+ }))
6699
+ )
6700
+ }));
6701
+ this.libraryIndex = { pages, fetchedAt: Date.now() };
6702
+ } catch {
6703
+ }
6704
+ }
6705
+ /**
6706
+ * Enrich a test step's suggestedStrategies using the cached library index.
6707
+ * Finds matching elements by description and injects their strategies.
6708
+ */
6709
+ enrichStepFromLibrary(step) {
6710
+ if (!this.libraryIndex) return step;
6711
+ if (step.action === "navigate") return step;
6712
+ const description = step.target?.elementDescription || step.description;
6713
+ if (!description) return step;
6714
+ const currentUrl = this.page?.url() ?? "";
6715
+ const descLower = description.toLowerCase().trim();
6716
+ let matchingPages = this.libraryIndex.pages;
6717
+ if (currentUrl) {
6718
+ try {
6719
+ const urlPath = new URL(currentUrl).pathname;
6720
+ const exactPageMatch = matchingPages.filter(
6721
+ (p) => urlPath === p.urlPattern || urlPath.startsWith(p.urlPattern)
6722
+ );
6723
+ if (exactPageMatch.length > 0) {
6724
+ matchingPages = exactPageMatch;
6725
+ }
6726
+ } catch {
6727
+ }
6728
+ }
6729
+ const allElements = matchingPages.flatMap((p) => p.elements);
6730
+ if (allElements.length === 0) return step;
6731
+ let bestMatch = null;
6732
+ let bestScore = 0;
6733
+ const THRESHOLD = 0.7;
6734
+ for (const el of allElements) {
6735
+ let score = 0;
6736
+ const elNameLower = el.name.toLowerCase();
6737
+ if (descLower === elNameLower) {
6738
+ score = 1;
6739
+ } else if (el.aliases.some((a) => a.toLowerCase() === descLower)) {
6740
+ score = 0.95;
6741
+ } else if (elNameLower.includes(descLower) || descLower.includes(elNameLower)) {
6742
+ score = 0.8;
6743
+ } else {
6744
+ const descWords = descLower.split(/\s+/);
6745
+ const nameWords = elNameLower.split(/\s+/);
6746
+ const overlap = descWords.filter((w) => nameWords.includes(w)).length;
6747
+ const maxWords = Math.max(descWords.length, nameWords.length);
6748
+ if (maxWords > 0) {
6749
+ score = 0.6 * (overlap / maxWords);
6750
+ }
6751
+ }
6752
+ if (el.elementRole && descLower.includes(el.elementRole.toLowerCase())) {
6753
+ score = Math.min(1, score + 0.1);
6754
+ }
6755
+ if (score > bestScore) {
6756
+ bestScore = score;
6757
+ bestMatch = el;
6758
+ }
6759
+ }
6760
+ if (!bestMatch || bestScore < THRESHOLD) return step;
6761
+ const libraryStrategies = bestMatch.strategies.sort((a, b) => a.rank - b.rank).map((s) => ({
6762
+ type: s.type,
6763
+ value: s.value,
6764
+ confidence: s.confidence,
6765
+ playwrightLocator: s.playwrightLocator
6766
+ }));
6767
+ if (libraryStrategies.length === 0) return step;
6768
+ const existingStrategies = step.target?.suggestedStrategies ?? [];
6769
+ const existingKeys = new Set(existingStrategies.map((s) => `${s.type}:${s.value}`));
6770
+ const mergedStrategies = [
6771
+ ...libraryStrategies.filter((s) => !existingKeys.has(`${s.type}:${s.value}`)),
6772
+ ...existingStrategies
6773
+ ];
6774
+ return {
6775
+ ...step,
6776
+ target: {
6777
+ elementDescription: step.target?.elementDescription || description,
6778
+ suggestedStrategies: mergedStrategies,
6779
+ elementId: step.target?.elementId
6780
+ }
6781
+ };
6782
+ }
6559
6783
  /**
6560
6784
  * Convert hybrid results to standard StepResult format
6561
6785
  */
@@ -6782,8 +7006,72 @@ function inferRoleFromDescription(description) {
6782
7006
  }
6783
7007
  return null;
6784
7008
  }
7009
+ function createLocatorFromPlaywrightString(page, playwrightLocator) {
7010
+ try {
7011
+ const testidMatch = playwrightLocator.match(/^getByTestId\(['"](.+?)['"]\)$/);
7012
+ if (testidMatch) {
7013
+ return {
7014
+ locator: page.getByTestId(testidMatch[1]),
7015
+ selectorString: `testid="${testidMatch[1]}"`
7016
+ };
7017
+ }
7018
+ const roleWithNameMatch = playwrightLocator.match(
7019
+ /^getByRole\(['"](.+?)['"],\s*\{\s*name:\s*['"](.+?)['"]\s*\}\)$/
7020
+ );
7021
+ if (roleWithNameMatch) {
7022
+ return {
7023
+ locator: page.getByRole(roleWithNameMatch[1], {
7024
+ name: roleWithNameMatch[2]
7025
+ }),
7026
+ selectorString: `role=${roleWithNameMatch[1]}[name="${roleWithNameMatch[2]}"]`
7027
+ };
7028
+ }
7029
+ const roleMatch = playwrightLocator.match(/^getByRole\(['"](.+?)['"]\)$/);
7030
+ if (roleMatch) {
7031
+ return {
7032
+ locator: page.getByRole(roleMatch[1]),
7033
+ selectorString: `role=${roleMatch[1]}`
7034
+ };
7035
+ }
7036
+ const labelMatch = playwrightLocator.match(/^getByLabel\(['"](.+?)['"]\)$/);
7037
+ if (labelMatch) {
7038
+ return {
7039
+ locator: page.getByLabel(labelMatch[1]),
7040
+ selectorString: `label="${labelMatch[1]}"`
7041
+ };
7042
+ }
7043
+ const placeholderMatch = playwrightLocator.match(/^getByPlaceholder\(['"](.+?)['"]\)$/);
7044
+ if (placeholderMatch) {
7045
+ return {
7046
+ locator: page.getByPlaceholder(placeholderMatch[1]),
7047
+ selectorString: `placeholder="${placeholderMatch[1]}"`
7048
+ };
7049
+ }
7050
+ const textMatch = playwrightLocator.match(/^getByText\(['"](.+?)['"]\)$/);
7051
+ if (textMatch) {
7052
+ return {
7053
+ locator: page.getByText(textMatch[1]),
7054
+ selectorString: `text="${textMatch[1]}"`
7055
+ };
7056
+ }
7057
+ const locatorMatch = playwrightLocator.match(/^locator\(['"](.+?)['"]\)$/);
7058
+ if (locatorMatch) {
7059
+ return {
7060
+ locator: page.locator(locatorMatch[1]),
7061
+ selectorString: `css=${locatorMatch[1]}`
7062
+ };
7063
+ }
7064
+ return null;
7065
+ } catch {
7066
+ return null;
7067
+ }
7068
+ }
6785
7069
  async function createLocatorForStrategy(page, strategy, options) {
6786
7070
  const { exact = false } = options;
7071
+ if (strategy.playwrightLocator) {
7072
+ const result = createLocatorFromPlaywrightString(page, strategy.playwrightLocator);
7073
+ if (result) return result;
7074
+ }
6787
7075
  switch (strategy.type) {
6788
7076
  case "role": {
6789
7077
  const [role, ...nameParts] = strategy.value.split(":");
@@ -6862,7 +7150,7 @@ function sortStrategies(strategies) {
6862
7150
  return b.confidence - a.confidence;
6863
7151
  });
6864
7152
  }
6865
- async function resolveSelector(page, hint, options = {}) {
7153
+ async function resolveSelector2(page, hint, options = {}) {
6866
7154
  const { timeout = 5e3, maxAttempts } = options;
6867
7155
  const attemptedStrategies = [];
6868
7156
  const sortedStrategies = sortStrategies(hint.suggestedStrategies);
@@ -7014,7 +7302,7 @@ async function resolveTarget(page, step, timeout) {
7014
7302
  step.target.elementDescription
7015
7303
  )
7016
7304
  };
7017
- return resolveSelector(page, hint, { timeout });
7305
+ return resolveSelector2(page, hint, { timeout });
7018
7306
  }
7019
7307
  async function handleNavigate(page, step, options) {
7020
7308
  const { timeout = 3e4 } = options;
@@ -7692,6 +7980,7 @@ async function executeWithSelectors(testPlan, options) {
7692
7980
 
7693
7981
  // src/commands/run/executors/hybrid-executor.ts
7694
7982
  import chalk13 from "chalk";
7983
+ init_config();
7695
7984
  async function executeWithHybrid(testPlan, options) {
7696
7985
  const { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential } = options;
7697
7986
  const config = await getCliConfig().catch(() => null);
@@ -7703,6 +7992,8 @@ async function executeWithHybrid(testPlan, options) {
7703
7992
  }
7704
7993
  const learnedSelectors = [];
7705
7994
  const modelOverride = process.env.AI_MODEL;
7995
+ const apiBaseUrl = getApiUrl();
7996
+ const apiToken = getToken();
7706
7997
  const hybridExecutor = new HybridTestExecutor({
7707
7998
  baseUrl: url,
7708
7999
  headed,
@@ -7714,6 +8005,10 @@ async function executeWithHybrid(testPlan, options) {
7714
8005
  storageStatePath,
7715
8006
  actionModel: modelOverride || void 0,
7716
8007
  assertionModel: modelOverride || void 0,
8008
+ // Selector Library integration: AI agent will call /selector-learn after successful fallbacks
8009
+ apiBaseUrl: apiBaseUrl || void 0,
8010
+ apiToken: apiToken || void 0,
8011
+ projectId: options.projectId || void 0,
7717
8012
  visionClientOptions: apiKey ? {
7718
8013
  apiKey,
7719
8014
  timeout: 3e4
@@ -8025,8 +8320,8 @@ function displayHybridSummary(summary, results, isPretty) {
8025
8320
  // src/commands/run/executors/selector-learning.ts
8026
8321
  import chalk15 from "chalk";
8027
8322
  async function handleAISelectorLearning(learnedSelectors, options) {
8028
- const { projectId, testCaseId, learnSelectors, yes, verbose, isPretty } = options;
8029
- if (!learnSelectors) {
8323
+ const { projectId, learnSelectors: shouldLearn, yes, verbose, isPretty, targetUrl } = options;
8324
+ if (!shouldLearn) {
8030
8325
  return;
8031
8326
  }
8032
8327
  if (learnedSelectors.length === 0) {
@@ -8038,41 +8333,26 @@ async function handleAISelectorLearning(learnedSelectors, options) {
8038
8333
  if (isPretty) {
8039
8334
  displayLearnedSelectors(learnedSelectors, verbose);
8040
8335
  }
8041
- if (projectId && testCaseId) {
8336
+ if (projectId) {
8042
8337
  let shouldSave = yes;
8043
8338
  if (!shouldSave && isPretty) {
8044
8339
  shouldSave = await promptConfirmation(
8045
- chalk15.yellow("Save learned selectors to test case?")
8340
+ chalk15.yellow("Save learned selectors to library?")
8046
8341
  );
8047
8342
  }
8048
8343
  if (shouldSave) {
8049
- const saveSpinner = isPretty ? createSpinner("Saving learned selectors...").start() : null;
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
- }
8344
+ await saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose);
8065
8345
  } else if (isPretty) {
8066
8346
  log.dim("Selector learning skipped.");
8067
8347
  }
8068
8348
  } else if (isPretty) {
8069
- log.dim("Selectors displayed but not saved (no specific test case).");
8070
- log.dim("Use --project and --test-case to save selectors.");
8349
+ log.dim("Selectors displayed but not saved (no project configured).");
8350
+ log.dim("Use --project to save selectors to the library.");
8071
8351
  }
8072
8352
  }
8073
8353
  async function handleHybridSelectorLearning(learnedSelectors, options) {
8074
- const { projectId, testCaseId, learnSelectors, yes, verbose, isPretty } = options;
8075
- if (!learnSelectors) {
8354
+ const { projectId, learnSelectors: shouldLearn, yes, verbose, isPretty, targetUrl } = options;
8355
+ if (!shouldLearn) {
8076
8356
  return;
8077
8357
  }
8078
8358
  if (learnedSelectors.length === 0) {
@@ -8084,36 +8364,50 @@ async function handleHybridSelectorLearning(learnedSelectors, options) {
8084
8364
  if (isPretty) {
8085
8365
  displayLearnedSelectors(learnedSelectors, verbose);
8086
8366
  }
8087
- if (projectId && testCaseId) {
8367
+ if (projectId) {
8088
8368
  let shouldSave = yes;
8089
8369
  if (!shouldSave && isPretty) {
8090
8370
  shouldSave = await promptConfirmation(
8091
- chalk15.yellow("Save learned selectors to test case?")
8371
+ chalk15.yellow("Save learned selectors to library?")
8092
8372
  );
8093
8373
  }
8094
8374
  if (shouldSave) {
8095
- const saveSpinner = isPretty ? createSpinner("Saving learned selectors...").start() : null;
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
- }
8375
+ await saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose);
8111
8376
  } else if (isPretty) {
8112
8377
  log.dim("Selector learning skipped.");
8113
8378
  }
8114
8379
  } else if (isPretty) {
8115
- log.dim("Selectors displayed but not saved (no specific test case).");
8116
- log.dim("Use --project and --test-case to save selectors.");
8380
+ log.dim("Selectors displayed but not saved (no project configured).");
8381
+ log.dim("Use --project to save selectors to the library.");
8382
+ }
8383
+ }
8384
+ async function saveToLibrary(projectId, learnedSelectors, targetUrl, isPretty, verbose) {
8385
+ const saveSpinner = isPretty ? createSpinner("Saving selectors to library...").start() : null;
8386
+ try {
8387
+ const elements = learnedSelectors.map((ls) => ({
8388
+ elementDescription: ls.elementDescription || "Unknown element",
8389
+ strategies: ls.strategies.map((s) => ({
8390
+ type: s.type || "css",
8391
+ value: s.value || "",
8392
+ playwrightLocator: s.playwrightLocator || s.value || "",
8393
+ confidence: s.confidence ?? 0.8
8394
+ }))
8395
+ }));
8396
+ const validElements = elements.filter((e) => e.strategies.length > 0);
8397
+ const result = await learnSelectors(
8398
+ projectId,
8399
+ targetUrl || "",
8400
+ validElements
8401
+ );
8402
+ const count = result.created.elements + result.updated.elements;
8403
+ saveSpinner?.succeed(
8404
+ `Saved ${count} selector${count === 1 ? "" : "s"} to library`
8405
+ );
8406
+ } catch (saveError) {
8407
+ saveSpinner?.fail("Failed to save selectors to library");
8408
+ if (verbose && saveError instanceof Error) {
8409
+ console.warn(chalk15.yellow(` ${saveError.message}`));
8410
+ }
8117
8411
  }
8118
8412
  }
8119
8413
 
@@ -8133,7 +8427,7 @@ async function runCommand(options) {
8133
8427
  format = "pretty",
8134
8428
  verbose = false,
8135
8429
  timeout = 3e4,
8136
- learnSelectors = false,
8430
+ learnSelectors: learnSelectors2 = false,
8137
8431
  yes = false,
8138
8432
  noDedup = false,
8139
8433
  autoMerge = false,
@@ -8155,7 +8449,28 @@ async function runCommand(options) {
8155
8449
  }
8156
8450
  const projectId = project || getActiveProject();
8157
8451
  let url = rawUrl;
8158
- if (envName) {
8452
+ let resolvedEnvName = envName || projectConfig?.environment || "local";
8453
+ let envAuth = projectConfig?.auth;
8454
+ const envConfig = resolveEnvironmentConfig(envName || projectConfig?.environment, projectConfig);
8455
+ if (envConfig) {
8456
+ if (!rawUrl) {
8457
+ url = envConfig.baseUrl || rawUrl;
8458
+ }
8459
+ resolvedEnvName = envConfig.envName;
8460
+ if (envConfig.auth) {
8461
+ envAuth = envConfig.auth;
8462
+ }
8463
+ if (envConfig.apiUrl) {
8464
+ overrideApiUrl(envConfig.apiUrl);
8465
+ }
8466
+ if (isPretty) {
8467
+ log.info(`Using environment "${envConfig.envName}" ${chalk16.dim("(local)")}: ${chalk16.cyan(envConfig.baseUrl || "N/A")}`);
8468
+ if (envConfig.apiUrl) {
8469
+ log.info(` API: ${chalk16.cyan(envConfig.apiUrl)}`);
8470
+ }
8471
+ log.newline();
8472
+ }
8473
+ } else if (envName) {
8159
8474
  try {
8160
8475
  if (isPretty) {
8161
8476
  log.info(`Resolving environment "${envName}"...`);
@@ -8208,11 +8523,10 @@ async function runCommand(options) {
8208
8523
  }
8209
8524
  }
8210
8525
  let storageStatePath;
8211
- const authConfig = projectConfig?.auth;
8212
- if (authConfig) {
8213
- const statePath = path7.resolve(authConfig.storageStatePath || DEFAULT_STATE_PATH);
8214
- const maxAge = authConfig.maxAge || DEFAULT_MAX_AGE;
8215
- const credentials = { ...authConfig.credentials };
8526
+ if (envAuth) {
8527
+ const statePath = path7.resolve(envAuth.storageStatePath || DEFAULT_STATE_PATH);
8528
+ const maxAge = envAuth.maxAge || DEFAULT_MAX_AGE;
8529
+ const credentials = { ...envAuth.credentials };
8216
8530
  if (AuthExecutor.hasFreshState(statePath, maxAge)) {
8217
8531
  if (isPretty) {
8218
8532
  log.dim(" Using cached auth state");
@@ -8224,7 +8538,7 @@ async function runCommand(options) {
8224
8538
  }
8225
8539
  try {
8226
8540
  const targetUrl = url || projectConfig?.baseUrl || "";
8227
- storageStatePath = await AuthExecutor.authenticate(authConfig, targetUrl, credentials);
8541
+ storageStatePath = await AuthExecutor.authenticate(envAuth, targetUrl, credentials);
8228
8542
  if (isPretty) {
8229
8543
  console.log(chalk16.green(" \u2713 Authenticated"));
8230
8544
  }
@@ -8264,7 +8578,9 @@ async function runCommand(options) {
8264
8578
  generatedFromFile,
8265
8579
  fileGeneratedTestCases,
8266
8580
  projectConfig,
8267
- isPretty
8581
+ isPretty,
8582
+ environment: resolvedEnvName,
8583
+ authEnabled: !!envAuth
8268
8584
  });
8269
8585
  await runTestExecution({
8270
8586
  testPlan,
@@ -8278,7 +8594,7 @@ async function runCommand(options) {
8278
8594
  reporter,
8279
8595
  projectId,
8280
8596
  testCaseId,
8281
- learnSelectors,
8597
+ learnSelectors: learnSelectors2,
8282
8598
  yes,
8283
8599
  recordVideo,
8284
8600
  recordTrace,
@@ -8335,7 +8651,7 @@ async function handleFileOption(file, projectId, isPretty, verbose, noDedup, aut
8335
8651
  }
8336
8652
  }
8337
8653
  async function runTestExecution(options) {
8338
- const { testPlan, runId, mode, url, headed, timeout, verbose, isPretty, reporter, projectId, testCaseId, learnSelectors, yes, recordVideo, recordTrace, storageStatePath, sequential } = options;
8654
+ const { testPlan, runId, mode, url, headed, timeout, verbose, isPretty, reporter, projectId, testCaseId, learnSelectors: learnSelectors2, yes, recordVideo, recordTrace, storageStatePath, sequential } = options;
8339
8655
  if (isPretty) {
8340
8656
  log.newline();
8341
8657
  const modeLabel = mode === "ai" ? chalk16.magenta("[AI Mode]") : mode === "hybrid" ? chalk16.yellow("[Hybrid Mode]") : chalk16.cyan("[Selector Mode]");
@@ -8362,15 +8678,15 @@ async function runTestExecution(options) {
8362
8678
  await submitAICorrections(aiResults.results, testPlan, projectId, isPretty, verbose);
8363
8679
  }
8364
8680
  const learnedSelectors = extractLearnedSelectors(aiResults.results);
8365
- await handleAISelectorLearning(learnedSelectors, { projectId, testCaseId, learnSelectors, yes, verbose, isPretty });
8681
+ await handleAISelectorLearning(learnedSelectors, { projectId, testCaseId, learnSelectors: learnSelectors2, yes, verbose, isPretty, targetUrl: url });
8366
8682
  } else if (mode === "hybrid") {
8367
- const hybridResults = await executeWithHybrid(testPlan, { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential });
8683
+ const hybridResults = await executeWithHybrid(testPlan, { url, headed, timeout, verbose, isPretty, recordVideo, recordTrace, storageStatePath, sequential, projectId });
8368
8684
  summary = hybridResults.summary;
8369
8685
  videoPath = hybridResults.videoPath;
8370
8686
  tracePath = hybridResults.tracePath;
8371
8687
  await submitHybridResults(runId, hybridResults.results, isPretty, verbose);
8372
8688
  displayHybridSummary(summary, hybridResults.results, isPretty);
8373
- await handleHybridSelectorLearning(hybridResults.learnedSelectors, { projectId, testCaseId, learnSelectors, yes, verbose, isPretty });
8689
+ await handleHybridSelectorLearning(hybridResults.learnedSelectors, { projectId, testCaseId, learnSelectors: learnSelectors2, yes, verbose, isPretty, targetUrl: url });
8374
8690
  } else {
8375
8691
  const results = await executeWithSelectors(testPlan, { url, headed, timeout, isPretty, reporter, recordVideo, recordTrace, storageStatePath, sequential });
8376
8692
  summary = { passed: results.summary.passed, failed: results.summary.failed, total: results.summary.total };
@@ -9505,14 +9821,13 @@ async function featuresListCommand() {
9505
9821
  const maxSlugLen = Math.max(4, ...features.map((f) => f.slug.length));
9506
9822
  console.log(chalk24.bold("Features:"));
9507
9823
  console.log(
9508
- ` ${chalk24.dim("NAME".padEnd(maxNameLen))} ${chalk24.dim("SLUG".padEnd(maxSlugLen))} ${chalk24.dim("TEST CASES")} ${chalk24.dim("SELECTORS")}`
9824
+ ` ${chalk24.dim("NAME".padEnd(maxNameLen))} ${chalk24.dim("SLUG".padEnd(maxSlugLen))} ${chalk24.dim("TEST CASES")}`
9509
9825
  );
9510
9826
  for (const feature of features) {
9511
9827
  const name = feature.name.padEnd(maxNameLen);
9512
9828
  const slug = feature.slug.padEnd(maxSlugLen);
9513
9829
  const tcCount = String(feature.testCaseCount ?? 0).padStart(5);
9514
- const selCount = String(feature.verifiedSelectorCount ?? 0).padStart(5);
9515
- console.log(` ${name} ${chalk24.dim(slug)} ${tcCount} ${selCount}`);
9830
+ console.log(` ${name} ${chalk24.dim(slug)} ${tcCount}`);
9516
9831
  }
9517
9832
  log.newline();
9518
9833
  } catch (error) {
@@ -9573,113 +9888,590 @@ async function featuresAssignCommand(featureSlug, options) {
9573
9888
  // src/commands/selectors.ts
9574
9889
  import * as fs9 from "fs";
9575
9890
  import chalk25 from "chalk";
9576
- var STATUS_COLORS = {
9577
- verified: chalk25.green,
9578
- unverified: chalk25.gray,
9579
- stale: chalk25.yellow,
9580
- broken: chalk25.red
9581
- };
9582
- async function selectorsListCommand(options) {
9891
+ async function findPageByUrl(projectId, urlPattern) {
9892
+ const { pages } = await listSelectorPages(projectId);
9893
+ return pages.find(
9894
+ (p) => p.urlPattern === urlPattern || p.urlPattern === `/${urlPattern.replace(/^\//, "")}`
9895
+ );
9896
+ }
9897
+ async function findElementByName(projectId, elementName, pageUrlPattern) {
9898
+ let pageId;
9899
+ if (pageUrlPattern) {
9900
+ const page = await findPageByUrl(projectId, pageUrlPattern);
9901
+ if (page) pageId = page.id;
9902
+ }
9903
+ const { elements } = await listSelectorElements(projectId, { pageId });
9904
+ return elements.find(
9905
+ (e) => e.name.toLowerCase() === elementName.toLowerCase()
9906
+ );
9907
+ }
9908
+ async function selectorsPagesCommand(options) {
9583
9909
  const projectId = requireAuthAndProject();
9584
- const spinner = createSpinner("Fetching selectors...").start();
9910
+ if (options.page) {
9911
+ return selectorsPageDetailCommand(projectId, options.page);
9912
+ }
9913
+ const spinner = createSpinner("Fetching selector library...").start();
9585
9914
  try {
9586
- const { selectors } = await listSelectors(projectId, {
9587
- featureSlug: options.feature,
9588
- status: options.status
9589
- });
9915
+ const { pages } = await listSelectorPages(projectId);
9590
9916
  spinner.succeed(
9591
- `Found ${selectors.length} selector${selectors.length === 1 ? "" : "s"}`
9917
+ `Found ${pages.length} page${pages.length === 1 ? "" : "s"}`
9592
9918
  );
9593
9919
  log.newline();
9594
- if (selectors.length === 0) {
9595
- log.plain("No selectors found.");
9920
+ if (pages.length === 0) {
9921
+ log.plain("No selector pages found.");
9596
9922
  log.newline();
9597
- log.dim("Run tests to auto-discover selectors.");
9923
+ log.dim("Run tests to auto-discover selectors, or add pages manually:");
9924
+ log.dim(' testbro selectors add-page --name "Login Page" --url-pattern "/login"');
9598
9925
  log.newline();
9599
9926
  return;
9600
9927
  }
9601
- const maxElementLen = Math.max(
9602
- 12,
9603
- ...selectors.map((s) => s.elementName.length)
9604
- );
9605
- const maxTypeLen = Math.max(
9606
- 4,
9607
- ...selectors.map((s) => s.selectorType.length)
9608
- );
9609
- console.log(chalk25.bold("Verified Selectors:"));
9928
+ const { elements } = await listSelectorElements(projectId);
9929
+ const pageElementCounts = /* @__PURE__ */ new Map();
9930
+ const pageSectionIds = /* @__PURE__ */ new Map();
9931
+ for (const el of elements) {
9932
+ const pageUrl = el.section?.page?.urlPattern;
9933
+ const pageId = el.section?.page?.id;
9934
+ if (pageId) {
9935
+ pageElementCounts.set(pageId, (pageElementCounts.get(pageId) || 0) + 1);
9936
+ if (!pageSectionIds.has(pageId)) pageSectionIds.set(pageId, /* @__PURE__ */ new Set());
9937
+ pageSectionIds.get(pageId).add(el.sectionId);
9938
+ }
9939
+ }
9940
+ const maxNameLen = Math.max(4, ...pages.map((p) => p.name.length));
9941
+ const maxUrlLen = Math.max(11, ...pages.map((p) => p.urlPattern.length));
9942
+ console.log(chalk25.bold("Selector Pages:"));
9610
9943
  console.log(
9611
- ` ${chalk25.dim("ELEMENT NAME".padEnd(maxElementLen))} ${chalk25.dim("TYPE".padEnd(maxTypeLen))} ${chalk25.dim("STATUS".padEnd(10))} ${chalk25.dim("LAST VERIFIED")}`
9944
+ ` ${chalk25.dim("NAME".padEnd(maxNameLen))} ${chalk25.dim("URL PATTERN".padEnd(maxUrlLen))} ${chalk25.dim("SECTIONS")} ${chalk25.dim("ELEMENTS")}`
9612
9945
  );
9613
- for (const selector of selectors) {
9614
- const element = selector.elementName.padEnd(maxElementLen);
9615
- const type = selector.selectorType.padEnd(maxTypeLen);
9616
- const colorFn = STATUS_COLORS[selector.status] || chalk25.gray;
9617
- const status = colorFn(selector.status.padEnd(10));
9618
- const lastVerified = selector.lastVerifiedAt ? new Date(selector.lastVerifiedAt).toLocaleDateString() : "Never";
9619
- console.log(` ${element} ${type} ${status} ${chalk25.dim(lastVerified)}`);
9946
+ for (const page of pages) {
9947
+ const name = page.name.padEnd(maxNameLen);
9948
+ const url = page.urlPattern.padEnd(maxUrlLen);
9949
+ const sectionCount = page._count?.sections ?? 0;
9950
+ const elementCount = pageElementCounts.get(page.id) ?? 0;
9951
+ console.log(
9952
+ ` ${name} ${chalk25.dim(url)} ${String(sectionCount).padStart(8)} ${String(elementCount).padStart(8)}`
9953
+ );
9620
9954
  }
9621
9955
  log.newline();
9622
9956
  } catch (error) {
9623
- spinner.fail("Failed to fetch selectors");
9957
+ spinner.fail("Failed to fetch selector library");
9624
9958
  handleCliError(error);
9625
9959
  process.exit(1);
9626
9960
  }
9627
9961
  }
9628
- async function selectorsVerifyCommand(options) {
9629
- const projectId = requireAuthAndProject();
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();
9962
+ async function selectorsPageDetailCommand(projectId, pageUrlPattern) {
9963
+ const spinner = createSpinner(`Fetching page "${pageUrlPattern}"...`).start();
9633
9964
  try {
9634
- const result = await bulkVerifySelectors(projectId, selectorIds);
9635
- spinner.succeed("Verification complete");
9636
- log.newline();
9637
- console.log(chalk25.bold("Results:"));
9638
- console.log(` ${chalk25.dim("Total re-verified:")} ${result.total}`);
9639
- console.log(` ${chalk25.dim("Verified:")} ${chalk25.green(String(result.verified))}`);
9640
- console.log(` ${chalk25.dim("Stale:")} ${chalk25.yellow(String(result.stale))}`);
9965
+ const page = await findPageByUrl(projectId, pageUrlPattern);
9966
+ if (!page) {
9967
+ spinner.fail(`Page with URL pattern "${pageUrlPattern}" not found`);
9968
+ return;
9969
+ }
9970
+ const { elements } = await listSelectorElements(projectId, { pageId: page.id });
9971
+ spinner.succeed(`${page.name} (${page.urlPattern})`);
9641
9972
  log.newline();
9973
+ if (elements.length === 0) {
9974
+ log.plain("No elements found for this page.");
9975
+ log.newline();
9976
+ return;
9977
+ }
9978
+ const sectionMap = /* @__PURE__ */ new Map();
9979
+ for (const el of elements) {
9980
+ const sectionId = el.sectionId;
9981
+ if (!sectionMap.has(sectionId)) sectionMap.set(sectionId, []);
9982
+ sectionMap.get(sectionId).push(el);
9983
+ }
9984
+ const sectionNames = /* @__PURE__ */ new Map();
9985
+ for (const el of elements) {
9986
+ const sectionId = el.sectionId;
9987
+ if (!sectionNames.has(sectionId)) {
9988
+ sectionNames.set(sectionId, sectionId);
9989
+ }
9990
+ }
9991
+ for (const [sectionId, sectionElements] of sectionMap) {
9992
+ const sectionName = sectionNames.get(sectionId) || "Unknown Section";
9993
+ console.log(` ${chalk25.bold.underline(sectionName)}`);
9994
+ for (const el of sectionElements) {
9995
+ const topStrategy = el.strategies[0];
9996
+ const strategyStr = topStrategy ? `${topStrategy.type}="${topStrategy.value}"` : chalk25.dim("no strategies");
9997
+ const statusIcon2 = el.status === "active" ? icons.success : el.status === "broken" ? icons.fail : chalk25.yellow("~");
9998
+ const totalSuccess = el.strategies.reduce((sum, s) => sum + s.successCount, 0);
9999
+ console.log(
10000
+ ` ${statusIcon2} ${el.name.padEnd(25)} ${chalk25.dim(strategyStr.padEnd(35))} ${chalk25.dim(`${totalSuccess} successes`)}`
10001
+ );
10002
+ }
10003
+ log.newline();
10004
+ }
9642
10005
  } catch (error) {
9643
- spinner.fail("Failed to verify selectors");
10006
+ spinner.fail("Failed to fetch page details");
9644
10007
  handleCliError(error);
9645
10008
  process.exit(1);
9646
10009
  }
9647
10010
  }
9648
- async function selectorsExportCommand(options) {
10011
+ async function selectorsElementsCommand(options) {
9649
10012
  const projectId = requireAuthAndProject();
9650
- const format = options.format === "csv" ? "csv" : "json";
9651
- const spinner = createSpinner(`Exporting selectors as ${format.toUpperCase()}...`).start();
10013
+ const spinner = createSpinner("Fetching elements...").start();
9652
10014
  try {
9653
- let featureId;
9654
- if (options.feature) {
9655
- const { features } = await listFeatures(projectId);
9656
- const feature = features.find((f) => f.slug === options.feature);
9657
- if (!feature) {
9658
- spinner.fail(`Feature "${options.feature}" not found`);
9659
- process.exit(1);
9660
- }
9661
- featureId = feature.id;
10015
+ let pageId;
10016
+ if (options.page) {
10017
+ const page = await findPageByUrl(projectId, options.page);
10018
+ if (page) pageId = page.id;
9662
10019
  }
9663
- const content = await exportSelectors(projectId, {
9664
- format,
10020
+ const { elements } = await listSelectorElements(projectId, {
10021
+ pageId,
9665
10022
  status: options.status,
9666
- featureId
10023
+ tag: options.tag
9667
10024
  });
9668
- if (options.output) {
9669
- fs9.writeFileSync(options.output, content, "utf-8");
9670
- spinner.succeed(`Exported to ${options.output}`);
9671
- } else {
9672
- spinner.succeed(`Selectors exported (${format.toUpperCase()})`);
10025
+ spinner.succeed(`Found ${elements.length} element${elements.length === 1 ? "" : "s"}`);
10026
+ log.newline();
10027
+ if (elements.length === 0) {
10028
+ log.plain("No elements found matching the criteria.");
9673
10029
  log.newline();
9674
- console.log(content);
10030
+ return;
10031
+ }
10032
+ const maxNameLen = Math.max(4, ...elements.map((e) => e.name.length));
10033
+ console.log(chalk25.bold("Elements:"));
10034
+ console.log(
10035
+ ` ${chalk25.dim("NAME".padEnd(maxNameLen))} ${chalk25.dim("PAGE".padEnd(20))} ${chalk25.dim("STATUS".padEnd(10))} ${chalk25.dim("STRATEGIES")} ${chalk25.dim("ROLE")}`
10036
+ );
10037
+ for (const el of elements) {
10038
+ const name = el.name.padEnd(maxNameLen);
10039
+ const pageName = (el.section?.page?.urlPattern || "").padEnd(20);
10040
+ const status = el.status.padEnd(10);
10041
+ const stratCount = String(el.strategies.length).padStart(10);
10042
+ const role = el.elementRole || "-";
10043
+ const statusColor = el.status === "active" ? chalk25.green(status) : el.status === "broken" ? chalk25.red(status) : chalk25.yellow(status);
10044
+ console.log(
10045
+ ` ${name} ${chalk25.dim(pageName)} ${statusColor} ${stratCount} ${chalk25.dim(role)}`
10046
+ );
9675
10047
  }
9676
10048
  log.newline();
9677
10049
  } catch (error) {
9678
- spinner.fail("Failed to export selectors");
10050
+ spinner.fail("Failed to fetch elements");
9679
10051
  handleCliError(error);
9680
10052
  process.exit(1);
9681
10053
  }
9682
10054
  }
10055
+ async function selectorsFindCommand(description) {
10056
+ const projectId = requireAuthAndProject();
10057
+ const spinner = createSpinner("Searching...").start();
10058
+ try {
10059
+ const result = await resolveSelector(projectId, {
10060
+ url: "/",
10061
+ elementDescription: description
10062
+ });
10063
+ if (!result.matched) {
10064
+ spinner.warn(`No match found (best score: ${((result.matchScore || 0) * 100).toFixed(0)}%)`);
10065
+ log.newline();
10066
+ if (result.element) {
10067
+ const el2 = result.element;
10068
+ log.dim(`Closest: ${el2.name} (score: ${((result.matchScore || 0) * 100).toFixed(0)}%)`);
10069
+ }
10070
+ log.newline();
10071
+ return;
10072
+ }
10073
+ const el = result.element;
10074
+ spinner.succeed(`MATCH (score: ${((result.matchScore || 0) * 100).toFixed(0)}%): ${el.name}`);
10075
+ log.newline();
10076
+ if (result.strategies && result.strategies.length > 0) {
10077
+ console.log(chalk25.bold(" Strategies:"));
10078
+ for (const s of result.strategies) {
10079
+ const successInfo = `rank=${s.rank}, confidence=${(s.confidence * 100).toFixed(0)}%`;
10080
+ console.log(
10081
+ ` [${s.rank}] ${s.type}="${s.value}" ${chalk25.dim(`(${successInfo})`)}`
10082
+ );
10083
+ }
10084
+ }
10085
+ log.newline();
10086
+ } catch (error) {
10087
+ spinner.fail("Search failed");
10088
+ handleCliError(error);
10089
+ process.exit(1);
10090
+ }
10091
+ }
10092
+ async function selectorsAddPageCommand(options) {
10093
+ const projectId = requireAuthAndProject();
10094
+ if (!options.name || !options.urlPattern) {
10095
+ log.error("Both --name and --url-pattern are required.");
10096
+ process.exit(1);
10097
+ }
10098
+ const spinner = createSpinner("Creating page...").start();
10099
+ try {
10100
+ const tags = options.tags ? options.tags.split(",").map((t) => t.trim()) : void 0;
10101
+ const { page } = await createSelectorPage(projectId, {
10102
+ name: options.name,
10103
+ urlPattern: options.urlPattern,
10104
+ tags
10105
+ });
10106
+ spinner.succeed(`Page created: ${page.name} (${page.urlPattern})`);
10107
+ log.newline();
10108
+ } catch (error) {
10109
+ spinner.fail("Failed to create page");
10110
+ handleCliError(error);
10111
+ process.exit(1);
10112
+ }
10113
+ }
10114
+ async function selectorsAddSectionCommand(options) {
10115
+ const projectId = requireAuthAndProject();
10116
+ if (!options.page || !options.name) {
10117
+ log.error("Both --page and --name are required.");
10118
+ process.exit(1);
10119
+ }
10120
+ const spinner = createSpinner("Creating section...").start();
10121
+ try {
10122
+ const page = await findPageByUrl(projectId, options.page);
10123
+ if (!page) {
10124
+ spinner.fail(`Page with URL pattern "${options.page}" not found`);
10125
+ return;
10126
+ }
10127
+ await createSelectorSection(projectId, page.id, {
10128
+ name: options.name
10129
+ });
10130
+ spinner.succeed(`Section created: ${options.name} (in ${page.name})`);
10131
+ log.newline();
10132
+ } catch (error) {
10133
+ spinner.fail("Failed to create section");
10134
+ handleCliError(error);
10135
+ process.exit(1);
10136
+ }
10137
+ }
10138
+ function parseStrategy(strategyStr) {
10139
+ const match = strategyStr.match(/^(\w+)=(.+)$/);
10140
+ if (!match) return null;
10141
+ const type = match[1];
10142
+ const value = match[2];
10143
+ let playwrightLocator;
10144
+ switch (type) {
10145
+ case "label":
10146
+ playwrightLocator = `getByLabel('${value}')`;
10147
+ break;
10148
+ case "testid":
10149
+ playwrightLocator = `getByTestId('${value}')`;
10150
+ break;
10151
+ case "role":
10152
+ playwrightLocator = value.includes(":") ? `getByRole('${value.split(":")[0]}', { name: '${value.split(":")[1]}' })` : `getByRole('${value}')`;
10153
+ break;
10154
+ case "placeholder":
10155
+ playwrightLocator = `getByPlaceholder('${value}')`;
10156
+ break;
10157
+ case "text":
10158
+ playwrightLocator = `getByText('${value}')`;
10159
+ break;
10160
+ case "css":
10161
+ playwrightLocator = `locator('${value}')`;
10162
+ break;
10163
+ case "id":
10164
+ playwrightLocator = `locator('#${value}')`;
10165
+ break;
10166
+ default:
10167
+ playwrightLocator = `locator('${value}')`;
10168
+ }
10169
+ return { type, value, playwrightLocator };
10170
+ }
10171
+ async function selectorsAddElementCommand(options) {
10172
+ const projectId = requireAuthAndProject();
10173
+ if (!options.page || !options.section || !options.name) {
10174
+ log.error("--page, --section, and --name are required.");
10175
+ process.exit(1);
10176
+ }
10177
+ const spinner = createSpinner("Creating element...").start();
10178
+ try {
10179
+ const page = await findPageByUrl(projectId, options.page);
10180
+ if (!page) {
10181
+ spinner.fail(`Page with URL pattern "${options.page}" not found`);
10182
+ return;
10183
+ }
10184
+ let sectionId;
10185
+ const { section } = await createSelectorSection(projectId, page.id, {
10186
+ name: options.section
10187
+ }).catch(async () => {
10188
+ const { elements: elems } = await listSelectorElements(projectId, { pageId: page.id });
10189
+ throw new Error(`Section "${options.section}" could not be created or found.`);
10190
+ });
10191
+ sectionId = section.id;
10192
+ const strategies = [];
10193
+ const strategyInputs = Array.isArray(options.strategy) ? options.strategy : options.strategy ? [options.strategy] : [];
10194
+ for (const s of strategyInputs) {
10195
+ const parsed = parseStrategy(s);
10196
+ if (parsed) {
10197
+ strategies.push({ ...parsed, confidence: 0.8, source: "manual" });
10198
+ }
10199
+ }
10200
+ const { element } = await createSelectorElement(projectId, {
10201
+ sectionId,
10202
+ name: options.name,
10203
+ elementRole: options.role,
10204
+ strategies: strategies.length > 0 ? strategies : void 0
10205
+ });
10206
+ spinner.succeed(`Element created: ${element.name} (${element.strategies.length} strategies)`);
10207
+ log.newline();
10208
+ } catch (error) {
10209
+ spinner.fail("Failed to create element");
10210
+ handleCliError(error);
10211
+ process.exit(1);
10212
+ }
10213
+ }
10214
+ async function selectorsHistoryCommand(options) {
10215
+ const projectId = requireAuthAndProject();
10216
+ if (!options.element) {
10217
+ log.error("--element is required.");
10218
+ process.exit(1);
10219
+ }
10220
+ const spinner = createSpinner("Fetching history...").start();
10221
+ try {
10222
+ const element = await findElementByName(projectId, options.element, options.page);
10223
+ if (!element) {
10224
+ spinner.fail(`Element "${options.element}" not found`);
10225
+ return;
10226
+ }
10227
+ const { history, total } = await getSelectorElementHistory(
10228
+ projectId,
10229
+ element.id,
10230
+ 20
10231
+ );
10232
+ spinner.succeed(`History for "${element.name}" (${total} entries)`);
10233
+ log.newline();
10234
+ if (history.length === 0) {
10235
+ log.plain("No history entries found.");
10236
+ log.newline();
10237
+ return;
10238
+ }
10239
+ console.log(chalk25.bold("History:"));
10240
+ for (let i = 0; i < history.length; i++) {
10241
+ const entry = history[i];
10242
+ const date = new Date(entry.createdAt).toLocaleString();
10243
+ const actionLabel = entry.action.replace(/_/g, " ");
10244
+ const changedBy = entry.changedBy || "unknown";
10245
+ const reason = entry.reason ? ` - ${entry.reason}` : "";
10246
+ console.log(
10247
+ ` ${chalk25.dim(`#${total - i}`)} ${chalk25.bold(actionLabel)} ${chalk25.dim(date)} by ${chalk25.cyan(changedBy)}${chalk25.dim(reason)}`
10248
+ );
10249
+ console.log(` ${chalk25.dim(`ID: ${entry.id}`)}`);
10250
+ }
10251
+ log.newline();
10252
+ } catch (error) {
10253
+ spinner.fail("Failed to fetch history");
10254
+ handleCliError(error);
10255
+ process.exit(1);
10256
+ }
10257
+ }
10258
+ async function selectorsRollbackCommand(options) {
10259
+ const projectId = requireAuthAndProject();
10260
+ if (!options.element || !options.version) {
10261
+ log.error("--element and --version are required.");
10262
+ process.exit(1);
10263
+ }
10264
+ const spinner = createSpinner("Rolling back...").start();
10265
+ try {
10266
+ const element = await findElementByName(projectId, options.element, options.page);
10267
+ if (!element) {
10268
+ spinner.fail(`Element "${options.element}" not found`);
10269
+ return;
10270
+ }
10271
+ const { history, total } = await getSelectorElementHistory(
10272
+ projectId,
10273
+ element.id,
10274
+ 200
10275
+ // fetch enough to find the version
10276
+ );
10277
+ const versionNum = parseInt(options.version, 10);
10278
+ const entryIndex = versionNum - 1;
10279
+ if (entryIndex < 0 || entryIndex >= history.length) {
10280
+ spinner.fail(`Version ${versionNum} not found. Available: 1-${history.length}`);
10281
+ return;
10282
+ }
10283
+ const historyEntry = history[entryIndex];
10284
+ await rollbackSelectorElement(projectId, element.id, historyEntry.id);
10285
+ spinner.succeed(`Rolled back "${element.name}" to version ${versionNum}`);
10286
+ log.newline();
10287
+ } catch (error) {
10288
+ spinner.fail("Rollback failed");
10289
+ handleCliError(error);
10290
+ process.exit(1);
10291
+ }
10292
+ }
10293
+ async function selectorsHealthCommand() {
10294
+ const projectId = requireAuthAndProject();
10295
+ const spinner = createSpinner("Checking selector health...").start();
10296
+ try {
10297
+ const { elements } = await listSelectorElements(projectId);
10298
+ const total = elements.length;
10299
+ const active = elements.filter((e) => e.status === "active").length;
10300
+ const broken = elements.filter((e) => e.status === "broken").length;
10301
+ const deprecated = elements.filter((e) => e.status === "deprecated").length;
10302
+ const now = Date.now();
10303
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1e3;
10304
+ const stale = elements.filter((e) => {
10305
+ if (e.status === "deprecated") return false;
10306
+ if (e.strategies.length === 0) return true;
10307
+ return e.strategies.every((s) => {
10308
+ if (!s.lastUsedAt) return true;
10309
+ return now - new Date(s.lastUsedAt).getTime() > sevenDaysMs;
10310
+ });
10311
+ });
10312
+ spinner.succeed("Selector Library Health");
10313
+ log.newline();
10314
+ console.log(` ${chalk25.bold("Total Elements:")} ${total}`);
10315
+ console.log(
10316
+ ` ${chalk25.green("Active:")} ${active} | ${chalk25.yellow("Stale:")} ${stale.length} | ${chalk25.red("Broken:")} ${broken} | ${chalk25.dim("Deprecated:")} ${deprecated}`
10317
+ );
10318
+ log.newline();
10319
+ if (broken > 0) {
10320
+ console.log(chalk25.bold.red(" BROKEN ELEMENTS:"));
10321
+ const brokenElements = elements.filter((e) => e.status === "broken");
10322
+ for (const el of brokenElements) {
10323
+ const totalSuccess = el.strategies.reduce((sum, s) => sum + s.successCount, 0);
10324
+ const totalFail = el.strategies.reduce((sum, s) => sum + s.failCount, 0);
10325
+ const lastFailed = el.strategies.map((s) => s.lastFailedAt).filter(Boolean).sort().reverse()[0];
10326
+ const failedAgo = lastFailed ? formatTimeAgo(new Date(lastFailed)) : "never";
10327
+ const pageName = el.section?.page?.name || "Unknown";
10328
+ console.log(
10329
+ ` ${icons.fail} ${pageName} > ${el.name} ${chalk25.dim(`(${totalSuccess}/${totalSuccess + totalFail} success, last failed ${failedAgo})`)}`
10330
+ );
10331
+ }
10332
+ log.newline();
10333
+ }
10334
+ if (stale.length > 0) {
10335
+ console.log(chalk25.bold.yellow(" STALE ELEMENTS:"));
10336
+ for (const el of stale.slice(0, 10)) {
10337
+ const lastUsed = el.strategies.map((s) => s.lastUsedAt).filter(Boolean).sort().reverse()[0];
10338
+ const usedAgo = lastUsed ? formatTimeAgo(new Date(lastUsed)) : "never used";
10339
+ const pageName = el.section?.page?.name || "Unknown";
10340
+ console.log(
10341
+ ` ${icons.warning} ${pageName} > ${el.name} ${chalk25.dim(`(${usedAgo})`)}`
10342
+ );
10343
+ }
10344
+ if (stale.length > 10) {
10345
+ log.dim(` ... and ${stale.length - 10} more`);
10346
+ }
10347
+ log.newline();
10348
+ }
10349
+ } catch (error) {
10350
+ spinner.fail("Failed to check health");
10351
+ handleCliError(error);
10352
+ process.exit(1);
10353
+ }
10354
+ }
10355
+ async function selectorsExportCommand(options) {
10356
+ const projectId = requireAuthAndProject();
10357
+ if (!options.output) {
10358
+ log.error("--output is required.");
10359
+ process.exit(1);
10360
+ }
10361
+ const spinner = createSpinner("Exporting selector library...").start();
10362
+ try {
10363
+ const exportData = await exportSelectorLibrary(projectId);
10364
+ if (options.page) {
10365
+ exportData.pages = exportData.pages.filter(
10366
+ (p) => p.urlPattern === options.page || p.urlPattern === `/${options.page.replace(/^\//, "")}`
10367
+ );
10368
+ }
10369
+ const json = JSON.stringify(exportData, null, 2);
10370
+ fs9.writeFileSync(options.output, json, "utf-8");
10371
+ const pageCount = exportData.pages.length;
10372
+ const elementCount = exportData.pages.reduce(
10373
+ (sum, p) => sum + p.sections.reduce(
10374
+ (sSum, s) => sSum + s.elements.length,
10375
+ 0
10376
+ ),
10377
+ 0
10378
+ );
10379
+ spinner.succeed(
10380
+ `Exported ${pageCount} page${pageCount === 1 ? "" : "s"}, ${elementCount} element${elementCount === 1 ? "" : "s"} to ${options.output}`
10381
+ );
10382
+ log.newline();
10383
+ } catch (error) {
10384
+ spinner.fail("Export failed");
10385
+ handleCliError(error);
10386
+ process.exit(1);
10387
+ }
10388
+ }
10389
+ async function selectorsImportCommand(options) {
10390
+ const projectId = requireAuthAndProject();
10391
+ if (!options.input) {
10392
+ log.error("--input is required.");
10393
+ process.exit(1);
10394
+ }
10395
+ if (!fs9.existsSync(options.input)) {
10396
+ log.error(`File not found: ${options.input}`);
10397
+ process.exit(1);
10398
+ }
10399
+ const spinner = createSpinner("Reading import file...").start();
10400
+ try {
10401
+ const raw = fs9.readFileSync(options.input, "utf-8");
10402
+ const data = JSON.parse(raw);
10403
+ if (!data.version || !Array.isArray(data.pages)) {
10404
+ spinner.fail("Invalid import file format");
10405
+ return;
10406
+ }
10407
+ const pageCount = data.pages.length;
10408
+ const elementCount = data.pages.reduce(
10409
+ (sum, p) => sum + p.sections.reduce(
10410
+ (sSum, s) => sSum + s.elements.length,
10411
+ 0
10412
+ ),
10413
+ 0
10414
+ );
10415
+ const strategyCount = data.pages.reduce(
10416
+ (sum, p) => sum + p.sections.reduce(
10417
+ (sSum, s) => sSum + s.elements.reduce(
10418
+ (eSum, e) => eSum + e.strategies.length,
10419
+ 0
10420
+ ),
10421
+ 0
10422
+ ),
10423
+ 0
10424
+ );
10425
+ if (options.dryRun) {
10426
+ spinner.succeed("Dry run - no changes will be made");
10427
+ log.newline();
10428
+ console.log(chalk25.bold("Import preview:"));
10429
+ console.log(` Pages: ${pageCount}`);
10430
+ console.log(` Elements: ${elementCount}`);
10431
+ console.log(` Strategies: ${strategyCount}`);
10432
+ log.newline();
10433
+ for (const page of data.pages) {
10434
+ console.log(` ${chalk25.bold(page.name)} (${page.urlPattern})`);
10435
+ for (const section of page.sections) {
10436
+ console.log(` ${section.name}`);
10437
+ for (const element of section.elements) {
10438
+ console.log(` ${element.name} (${element.strategies.length} strategies)`);
10439
+ }
10440
+ }
10441
+ }
10442
+ log.newline();
10443
+ return;
10444
+ }
10445
+ spinner.text("Importing...");
10446
+ const result = await importSelectorLibrary(projectId, data);
10447
+ spinner.succeed("Import complete");
10448
+ log.newline();
10449
+ console.log(chalk25.bold("Results:"));
10450
+ console.log(` Pages created: ${result.imported.pages}`);
10451
+ console.log(` Sections created: ${result.imported.sections}`);
10452
+ console.log(` Elements created: ${result.imported.elements}`);
10453
+ console.log(` Strategies created: ${result.imported.strategies}`);
10454
+ console.log(` Skipped (duplicates): ${result.skipped}`);
10455
+ log.newline();
10456
+ } catch (error) {
10457
+ spinner.fail("Import failed");
10458
+ handleCliError(error);
10459
+ process.exit(1);
10460
+ }
10461
+ }
10462
+ async function selectorsListCommand(options) {
10463
+ return selectorsPagesCommand({});
10464
+ }
10465
+ function formatTimeAgo(date) {
10466
+ const now = Date.now();
10467
+ const diff = now - date.getTime();
10468
+ const minutes = Math.floor(diff / 6e4);
10469
+ if (minutes < 60) return `${minutes}m ago`;
10470
+ const hours = Math.floor(minutes / 60);
10471
+ if (hours < 24) return `${hours}h ago`;
10472
+ const days = Math.floor(hours / 24);
10473
+ return `${days}d ago`;
10474
+ }
9683
10475
 
9684
10476
  // src/commands/demo.ts
9685
10477
  import chalk26 from "chalk";
@@ -9966,7 +10758,7 @@ var package_default = {
9966
10758
  publishConfig: {
9967
10759
  access: "public"
9968
10760
  },
9969
- version: "0.1.1",
10761
+ version: "0.1.3",
9970
10762
  description: "TestBro CLI - AI-powered browser testing from your terminal",
9971
10763
  type: "module",
9972
10764
  bin: {
@@ -10430,8 +11222,8 @@ featuresCmd.command("assign").description("Assign a test case to a feature by sl
10430
11222
  process.exit(1);
10431
11223
  }
10432
11224
  });
10433
- var selectorsCmd = program.command("selectors").description("List and manage verified selectors");
10434
- selectorsCmd.option("-f, --feature <slug>", "Filter by feature slug").option("-s, --status <status>", "Filter by status (verified, unverified, stale, broken)").action(async (options) => {
11225
+ var selectorsCmd = program.command("selectors").description("Browse and manage the selector library");
11226
+ selectorsCmd.option("-v, --verbose", "Show sections and elements").action(async (options) => {
10435
11227
  try {
10436
11228
  await selectorsListCommand(options);
10437
11229
  } catch (error) {
@@ -10442,9 +11234,31 @@ selectorsCmd.option("-f, --feature <slug>", "Filter by feature slug").option("-s
10442
11234
  process.exit(1);
10443
11235
  }
10444
11236
  });
10445
- selectorsCmd.command("verify").description("Bulk re-verify selectors (refreshes lastVerifiedAt)").option("--ids <ids>", "Comma-separated selector IDs to verify (default: all)").action(async (options) => {
11237
+ selectorsCmd.command("pages").description("List selector pages or show details for a specific page").option("-p, --page <urlPattern>", "Show detail tree for a specific page URL pattern").action(async (options) => {
11238
+ try {
11239
+ await selectorsPagesCommand(options);
11240
+ } catch (error) {
11241
+ if (error instanceof Error) {
11242
+ displayError(error.message);
11243
+ }
11244
+ printErrorHints(error);
11245
+ process.exit(1);
11246
+ }
11247
+ });
11248
+ selectorsCmd.command("elements").description("List elements with optional filters").option("-p, --page <urlPattern>", "Filter by page URL pattern").option("-s, --status <status>", "Filter by status (active, broken, deprecated)").option("-t, --tag <tag>", "Filter by tag").action(async (options) => {
11249
+ try {
11250
+ await selectorsElementsCommand(options);
11251
+ } catch (error) {
11252
+ if (error instanceof Error) {
11253
+ displayError(error.message);
11254
+ }
11255
+ printErrorHints(error);
11256
+ process.exit(1);
11257
+ }
11258
+ });
11259
+ selectorsCmd.command("find <description>").description("Fuzzy search for an element by description").action(async (description) => {
10446
11260
  try {
10447
- await selectorsVerifyCommand(options);
11261
+ await selectorsFindCommand(description);
10448
11262
  } catch (error) {
10449
11263
  if (error instanceof Error) {
10450
11264
  displayError(error.message);
@@ -10453,7 +11267,73 @@ selectorsCmd.command("verify").description("Bulk re-verify selectors (refreshes
10453
11267
  process.exit(1);
10454
11268
  }
10455
11269
  });
10456
- selectorsCmd.command("export").description("Export selectors as JSON or CSV").option("--format <format>", "Output format: json or csv", "json").option("--output <file>", "Write output to file instead of stdout").option("-s, --status <status>", "Filter by status (verified, stale, all)").option("-f, --feature <slug>", "Filter by feature slug").action(async (options) => {
11270
+ selectorsCmd.command("add-page").description("Add a new page to the selector library").requiredOption("-n, --name <name>", "Page name").requiredOption("--url-pattern <pattern>", "URL pattern for the page").option("--tags <tags>", "Comma-separated tags").action(async (options) => {
11271
+ try {
11272
+ await selectorsAddPageCommand(options);
11273
+ } catch (error) {
11274
+ if (error instanceof Error) {
11275
+ displayError(error.message);
11276
+ }
11277
+ printErrorHints(error);
11278
+ process.exit(1);
11279
+ }
11280
+ });
11281
+ selectorsCmd.command("add-section").description("Add a section to an existing page").requiredOption("-p, --page <urlPattern>", "Parent page URL pattern").requiredOption("-n, --name <name>", "Section name").action(async (options) => {
11282
+ try {
11283
+ await selectorsAddSectionCommand(options);
11284
+ } catch (error) {
11285
+ if (error instanceof Error) {
11286
+ displayError(error.message);
11287
+ }
11288
+ printErrorHints(error);
11289
+ process.exit(1);
11290
+ }
11291
+ });
11292
+ selectorsCmd.command("add-element").description("Add an element to a page section").requiredOption("-p, --page <urlPattern>", "Page URL pattern").requiredOption("--section <name>", "Section name").requiredOption("-n, --name <name>", "Element name").option("-r, --role <role>", "Element role (e.g. textbox, button)").option("-s, --strategy <strategies...>", "Strategies (e.g. label=Email testid=email-input)").action(async (options) => {
11293
+ try {
11294
+ await selectorsAddElementCommand(options);
11295
+ } catch (error) {
11296
+ if (error instanceof Error) {
11297
+ displayError(error.message);
11298
+ }
11299
+ printErrorHints(error);
11300
+ process.exit(1);
11301
+ }
11302
+ });
11303
+ selectorsCmd.command("history").description("Show change history for an element").requiredOption("-e, --element <name>", "Element name").option("-p, --page <urlPattern>", "Page URL pattern to narrow search").action(async (options) => {
11304
+ try {
11305
+ await selectorsHistoryCommand(options);
11306
+ } catch (error) {
11307
+ if (error instanceof Error) {
11308
+ displayError(error.message);
11309
+ }
11310
+ printErrorHints(error);
11311
+ process.exit(1);
11312
+ }
11313
+ });
11314
+ selectorsCmd.command("rollback").description("Rollback an element to a previous version").requiredOption("-e, --element <name>", "Element name").requiredOption("--version <n>", "Version number to rollback to (1 = most recent)").option("-p, --page <urlPattern>", "Page URL pattern to narrow search").action(async (options) => {
11315
+ try {
11316
+ await selectorsRollbackCommand(options);
11317
+ } catch (error) {
11318
+ if (error instanceof Error) {
11319
+ displayError(error.message);
11320
+ }
11321
+ printErrorHints(error);
11322
+ process.exit(1);
11323
+ }
11324
+ });
11325
+ selectorsCmd.command("health").description("Show health summary of the selector library").action(async () => {
11326
+ try {
11327
+ await selectorsHealthCommand();
11328
+ } catch (error) {
11329
+ if (error instanceof Error) {
11330
+ displayError(error.message);
11331
+ }
11332
+ printErrorHints(error);
11333
+ process.exit(1);
11334
+ }
11335
+ });
11336
+ selectorsCmd.command("export").description("Export the selector library to a JSON file").requiredOption("-o, --output <path>", "Output file path").option("-p, --page <urlPattern>", "Export only a specific page").action(async (options) => {
10457
11337
  try {
10458
11338
  await selectorsExportCommand(options);
10459
11339
  } catch (error) {
@@ -10464,6 +11344,17 @@ selectorsCmd.command("export").description("Export selectors as JSON or CSV").op
10464
11344
  process.exit(1);
10465
11345
  }
10466
11346
  });
11347
+ selectorsCmd.command("import").description("Import a selector library from a JSON file").requiredOption("-i, --input <path>", "Input file path").option("--dry-run", "Preview the import without making changes").action(async (options) => {
11348
+ try {
11349
+ await selectorsImportCommand(options);
11350
+ } catch (error) {
11351
+ if (error instanceof Error) {
11352
+ displayError(error.message);
11353
+ }
11354
+ printErrorHints(error);
11355
+ process.exit(1);
11356
+ }
11357
+ });
10467
11358
  var demoCmd = program.command("demo").description("Record and manage demo runs");
10468
11359
  demoCmd.action(async () => {
10469
11360
  try {
@@ -10610,6 +11501,24 @@ function displayConfig() {
10610
11501
  const autoMerge = pc.deduplication.autoMerge === true;
10611
11502
  console.log(` ${chalk29.dim("Deduplication:")} ${dedupEnabled ? chalk29.green("enabled") : chalk29.yellow("disabled")}${autoMerge ? chalk29.dim(" (auto-merge)") : ""}`);
10612
11503
  }
11504
+ if (pc.environments && Object.keys(pc.environments).length > 0) {
11505
+ const envNames = Object.keys(pc.environments);
11506
+ const activeEnv = pc.environment || "local";
11507
+ console.log(` ${chalk29.dim("Environments:")} ${envNames.map((name) => name === activeEnv ? chalk29.green(name + " *") : name).join(", ")}`);
11508
+ const activeEntry = pc.environments[activeEnv];
11509
+ if (activeEntry) {
11510
+ console.log(` ${chalk29.dim("Active env URL:")} ${activeEntry.baseUrl}`);
11511
+ if (activeEntry.apiUrl) {
11512
+ console.log(` ${chalk29.dim("Active env API:")} ${activeEntry.apiUrl}`);
11513
+ }
11514
+ if (activeEntry.credentials) {
11515
+ console.log(` ${chalk29.dim("Active env credentials:")} ${Object.keys(activeEntry.credentials).join(", ")}`);
11516
+ }
11517
+ if (activeEntry.auth) {
11518
+ console.log(` ${chalk29.dim("Active env auth:")} ${chalk29.green("configured")} ${chalk29.dim(`(${activeEntry.auth.loginUrl})`)}`);
11519
+ }
11520
+ }
11521
+ }
10613
11522
  }
10614
11523
  } else {
10615
11524
  console.log(` ${chalk29.dim("Not found")}`);