@skyramp/mcp 0.3.1 → 0.3.2-rc.pom-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/build/index.js +2 -1
  2. package/build/prompts/code-reuse.d.ts +7 -1
  3. package/build/prompts/code-reuse.js +8 -5
  4. package/build/prompts/code-reuse.test.d.ts +1 -0
  5. package/build/prompts/code-reuse.test.js +62 -0
  6. package/build/prompts/pom-aware-code-reuse.d.ts +6 -1
  7. package/build/prompts/pom-aware-code-reuse.js +100 -53
  8. package/build/prompts/pom-aware-code-reuse.test.d.ts +1 -0
  9. package/build/prompts/pom-aware-code-reuse.test.js +11 -0
  10. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +4 -2
  11. package/build/prompts/test-recommendation/diffExecutionPlan.js +11 -65
  12. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +2 -2
  13. package/build/prompts/test-recommendation/recommendationSections.js +5 -2
  14. package/build/prompts/test-recommendation/scopeAssessment.js +1 -1
  15. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +26 -1
  16. package/build/prompts/test-recommendation/test-recommendation-prompt.js +68 -56
  17. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +48 -11
  18. package/build/prompts/testbot/testbot-prompts.d.ts +1 -1
  19. package/build/prompts/testbot/testbot-prompts.js +67 -21
  20. package/build/prompts/testbot/testbot-prompts.test.js +44 -0
  21. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +7 -0
  22. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +71 -0
  23. package/build/recommendation/budgeters/diversityBalancedBudgeter.test.d.ts +1 -0
  24. package/build/recommendation/budgeters/diversityBalancedBudgeter.test.js +75 -0
  25. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +7 -0
  26. package/build/recommendation/budgeters/fixedNBudgeter.js +11 -0
  27. package/build/recommendation/budgeters/fixedNBudgeter.test.d.ts +1 -0
  28. package/build/recommendation/budgeters/fixedNBudgeter.test.js +66 -0
  29. package/build/recommendation/budgeters/shared.d.ts +19 -0
  30. package/build/recommendation/budgeters/shared.js +66 -0
  31. package/build/recommendation/discriminators.d.ts +31 -0
  32. package/build/recommendation/discriminators.js +355 -0
  33. package/build/recommendation/discriminators.test.d.ts +1 -0
  34. package/build/recommendation/discriminators.test.js +324 -0
  35. package/build/recommendation/diversity.d.ts +47 -0
  36. package/build/recommendation/diversity.js +101 -0
  37. package/build/recommendation/diversity.test.d.ts +1 -0
  38. package/build/recommendation/diversity.test.js +77 -0
  39. package/build/recommendation/planRanker.d.ts +50 -0
  40. package/build/recommendation/planRanker.js +67 -0
  41. package/build/recommendation/planRanker.test.d.ts +1 -0
  42. package/build/recommendation/planRanker.test.js +110 -0
  43. package/build/recommendation/testFixtures.d.ts +25 -0
  44. package/build/recommendation/testFixtures.js +45 -0
  45. package/build/resources/testbotResource.js +4 -1
  46. package/build/services/ScenarioGenerationService.d.ts +5 -0
  47. package/build/services/ScenarioGenerationService.js +16 -1
  48. package/build/services/ScenarioGenerationService.test.js +44 -0
  49. package/build/services/TestExecutionService.d.ts +15 -1
  50. package/build/services/TestExecutionService.js +210 -55
  51. package/build/services/TestExecutionService.test.js +397 -0
  52. package/build/services/TestGenerationService.js +19 -1
  53. package/build/services/TestGenerationService.test.js +58 -0
  54. package/build/tool-phases.js +1 -0
  55. package/build/toolNames.d.ts +19 -0
  56. package/build/toolNames.js +19 -0
  57. package/build/tools/code-refactor/codeReuseTool.d.ts +7 -0
  58. package/build/tools/code-refactor/codeReuseTool.js +130 -4
  59. package/build/tools/code-refactor/codeReuseTool.test.d.ts +1 -0
  60. package/build/tools/code-refactor/codeReuseTool.test.js +290 -0
  61. package/build/tools/executeSkyrampTestTool.js +8 -2
  62. package/build/tools/generate-tests/generateBatchScenarioRestTool.d.ts +6 -1
  63. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +110 -17
  64. package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +147 -0
  65. package/build/tools/generate-tests/generateContractRestTool.js +11 -1
  66. package/build/tools/generate-tests/generateIntegrationRestTool.js +24 -1
  67. package/build/tools/generate-tests/generateIntegrationRestTool.test.d.ts +1 -0
  68. package/build/tools/generate-tests/generateIntegrationRestTool.test.js +159 -0
  69. package/build/tools/generate-tests/planGuard.d.ts +13 -0
  70. package/build/tools/generate-tests/planGuard.js +78 -0
  71. package/build/tools/generate-tests/planGuard.test.d.ts +1 -0
  72. package/build/tools/generate-tests/planGuard.test.js +185 -0
  73. package/build/tools/generate-tests/scenarioFileIdentity.d.ts +10 -0
  74. package/build/tools/generate-tests/scenarioFileIdentity.js +46 -0
  75. package/build/tools/generate-tests/scenarioLint.d.ts +30 -0
  76. package/build/tools/generate-tests/scenarioLint.js +150 -0
  77. package/build/tools/generate-tests/scenarioLint.test.d.ts +1 -0
  78. package/build/tools/generate-tests/scenarioLint.test.js +100 -0
  79. package/build/tools/submitReportTool.js +78 -0
  80. package/build/tools/submitReportTool.test.js +255 -0
  81. package/build/tools/test-management/analyzeChangesTool.js +55 -2
  82. package/build/tools/test-management/analyzeChangesTool.test.js +12 -0
  83. package/build/tools/test-management/index.d.ts +1 -0
  84. package/build/tools/test-management/index.js +1 -0
  85. package/build/tools/test-management/registerTestPlanTool.d.ts +2 -0
  86. package/build/tools/test-management/registerTestPlanTool.js +329 -0
  87. package/build/tools/test-management/registerTestPlanTool.test.d.ts +1 -0
  88. package/build/tools/test-management/registerTestPlanTool.test.js +296 -0
  89. package/build/types/Recommendation.d.ts +97 -0
  90. package/build/types/Recommendation.js +48 -0
  91. package/build/types/RepositoryAnalysis.d.ts +14 -14
  92. package/build/types/TestExecution.d.ts +2 -0
  93. package/build/types/TestRecommendation.d.ts +12 -1
  94. package/build/types/TestRecommendation.js +26 -11
  95. package/build/types/TestTypes.js +1 -1
  96. package/build/utils/AnalysisStateManager.d.ts +47 -0
  97. package/build/utils/docker.test.js +1 -1
  98. package/build/utils/planMatchKeys.d.ts +61 -0
  99. package/build/utils/planMatchKeys.js +125 -0
  100. package/build/utils/pom-scope/import-expansion.d.ts +5 -0
  101. package/build/utils/pom-scope/import-expansion.js +32 -0
  102. package/build/utils/pom-scope/index.d.ts +39 -0
  103. package/build/utils/pom-scope/index.js +120 -0
  104. package/build/utils/pom-scope/index.test.d.ts +1 -0
  105. package/build/utils/pom-scope/index.test.js +239 -0
  106. package/build/utils/pom-scope/pom-files.d.ts +3 -0
  107. package/build/utils/pom-scope/pom-files.js +48 -0
  108. package/build/utils/pom-scope/pom-files.test.d.ts +1 -0
  109. package/build/utils/pom-scope/pom-files.test.js +29 -0
  110. package/build/utils/pom-scope/scoring.d.ts +20 -0
  111. package/build/utils/pom-scope/scoring.js +45 -0
  112. package/build/utils/pom-scope/scoring.test.d.ts +1 -0
  113. package/build/utils/pom-scope/scoring.test.js +39 -0
  114. package/build/utils/pom-scope/selector-extractor.d.ts +7 -0
  115. package/build/utils/pom-scope/selector-extractor.js +57 -0
  116. package/build/utils/pom-scope/selector-extractor.test.d.ts +1 -0
  117. package/build/utils/pom-scope/selector-extractor.test.js +67 -0
  118. package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.d.ts +5 -0
  119. package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.js +5 -0
  120. package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.d.ts +5 -0
  121. package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.js +9 -0
  122. package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.d.ts +4 -0
  123. package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.js +4 -0
  124. package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.d.ts +4 -0
  125. package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.js +4 -0
  126. package/build/utils/pom-verify/bindings.d.ts +19 -0
  127. package/build/utils/pom-verify/bindings.js +161 -0
  128. package/build/utils/pom-verify/bindings.test.d.ts +1 -0
  129. package/build/utils/pom-verify/bindings.test.js +164 -0
  130. package/build/utils/pom-verify/calls.d.ts +16 -0
  131. package/build/utils/pom-verify/calls.js +42 -0
  132. package/build/utils/pom-verify/calls.test.d.ts +1 -0
  133. package/build/utils/pom-verify/calls.test.js +61 -0
  134. package/build/utils/pom-verify/index.d.ts +4 -0
  135. package/build/utils/pom-verify/index.js +4 -0
  136. package/build/utils/pom-verify/resolve.d.ts +7 -0
  137. package/build/utils/pom-verify/resolve.js +27 -0
  138. package/build/utils/pom-verify/resolve.test.d.ts +1 -0
  139. package/build/utils/pom-verify/resolve.test.js +68 -0
  140. package/build/utils/pom-verify/strip.d.ts +9 -0
  141. package/build/utils/pom-verify/strip.js +89 -0
  142. package/build/utils/pom-verify/verify.d.ts +14 -0
  143. package/build/utils/pom-verify/verify.js +158 -0
  144. package/build/utils/pom-verify/verify.test.d.ts +1 -0
  145. package/build/utils/pom-verify/verify.test.js +325 -0
  146. package/build/utils/reportVerification.d.ts +61 -0
  147. package/build/utils/reportVerification.js +104 -0
  148. package/build/utils/reportVerification.test.d.ts +1 -0
  149. package/build/utils/reportVerification.test.js +185 -0
  150. package/build/utils/scenarioDrafting.js +5 -5
  151. package/build/utils/versions.d.ts +3 -3
  152. package/build/utils/versions.js +1 -1
  153. package/build/utils/workspaceAuth.d.ts +9 -1
  154. package/build/utils/workspaceAuth.js +25 -5
  155. package/build/utils/workspaceAuth.test.js +48 -0
  156. package/build/workspace/workspace.d.ts +20 -0
  157. package/build/workspace/workspace.js +4 -0
  158. package/build/workspace/workspace.test.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +77 -8
  160. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  161. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  162. package/node_modules/playwright/node_modules/playwright-core/lib/utils/isomorphic/volatileDate.js +101 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-aszq5EdG.js → codeMirrorModule-Bzd72-bG.js} +1 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-BxS7Jm4s.js → defaultSettingsView-DzxTioTK.js} +101 -101
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.D4JTTy4R.js → index.BGc30U3S.js} +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  168. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.DaRMQKOI.js → uiMode.IaDrb29A.js} +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  170. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  171. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  172. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  173. package/node_modules/playwright/node_modules/playwright-core/src/utils/isomorphic/volatileDate.ts +131 -0
  174. package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
  175. package/node_modules/playwright/package.json +1 -1
  176. package/package.json +3 -3
  177. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
@@ -0,0 +1,39 @@
1
+ export declare const SELECTION_CAP = 10;
2
+ export declare const FINAL_CAP = 25;
3
+ export declare function findRoot(startDir: string): Promise<string>;
4
+ /** Reads `use.testIdAttribute` out of a playwright.config.{js,ts,mjs,cjs} at root, if present. Never throws. */
5
+ export declare function readTestIdAttribute(root: string): Promise<string | undefined>;
6
+ export type ScopedPom = {
7
+ file: string;
8
+ score: number;
9
+ rareHits: number;
10
+ matchedTokens: string[];
11
+ };
12
+ export type ScopeResult = {
13
+ tier1: ScopedPom[];
14
+ tier2: ScopedPom[];
15
+ diagnostics: {
16
+ selectors: number;
17
+ pomFiles: number;
18
+ scoped: number;
19
+ /** How the POM set was found: conventional globs, selector-grep fallback (no conventional dirs matched),
20
+ * no discovery attempted/needed, or the repo was too large for the fallback scan to run. */
21
+ discovery: "globs" | "selector-grep" | "none" | "skipped-too-large";
22
+ };
23
+ };
24
+ /**
25
+ * Split candidates into tier1 (high relevance) / tier2 (supporting), capped at FINAL_CAP
26
+ * with lowest-score eviction. Note: the score arm uses strict `>` rather than the `>=`
27
+ * boundary — with `>=`, a candidate whose score lands exactly on maxScore/4 (an exact tie,
28
+ * not a rounding artifact — e.g. a token shared by exactly 4 files when another candidate's
29
+ * sole rare token is unique) would spuriously qualify for tier1 instead of tier2.
30
+ */
31
+ export declare function tierAndCap(candidates: ScopedPom[], maxScore: number): {
32
+ tier1: ScopedPom[];
33
+ tier2: ScopedPom[];
34
+ };
35
+ /** Deterministic POM pre-scoping. tier1/tier2 both [] ⇒ caller must fall back to the unscoped prompt. */
36
+ export declare function selectScopedPoms(opts: {
37
+ testFile: string;
38
+ specContent?: string;
39
+ }): Promise<ScopeResult>;
@@ -0,0 +1,120 @@
1
+ import { readFile, stat } from "fs/promises";
2
+ import * as path from "path";
3
+ import { extractSelectors, DEFAULT_TESTID_ATTRS } from "./selector-extractor.js";
4
+ import { globPomFiles, globAllSourceFiles } from "./pom-files.js";
5
+ import { scorePoms } from "./scoring.js";
6
+ import { expandImports } from "./import-expansion.js";
7
+ /** Beyond this many candidate source files, the selector-grep discovery fallback is skipped (too slow / too broad). */
8
+ const DISCOVERY_FILE_CAP = 3000;
9
+ export const SELECTION_CAP = 10;
10
+ export const FINAL_CAP = 25;
11
+ const PLAYWRIGHT_CONFIG_NAMES = ["playwright.config.ts", "playwright.config.js", "playwright.config.mjs", "playwright.config.cjs"];
12
+ const TESTID_ATTRIBUTE_RE = /testIdAttribute\s*:\s*["']([^"']+)["']/;
13
+ export async function findRoot(startDir) {
14
+ let dir = startDir;
15
+ for (let i = 0; i < 8; i++) {
16
+ for (const marker of ["package.json", "tsconfig.json", "jsconfig.json", ".git"]) {
17
+ try {
18
+ await stat(path.join(dir, marker));
19
+ return dir;
20
+ }
21
+ catch { /* keep walking */ }
22
+ }
23
+ const parent = path.dirname(dir);
24
+ if (parent === dir)
25
+ break;
26
+ dir = parent;
27
+ }
28
+ return startDir;
29
+ }
30
+ /** Reads `use.testIdAttribute` out of a playwright.config.{js,ts,mjs,cjs} at root, if present. Never throws. */
31
+ export async function readTestIdAttribute(root) {
32
+ for (const name of PLAYWRIGHT_CONFIG_NAMES) {
33
+ try {
34
+ const content = await readFile(path.join(root, name), "utf8");
35
+ const m = content.match(TESTID_ATTRIBUTE_RE);
36
+ if (m)
37
+ return m[1];
38
+ }
39
+ catch { /* try next filename */ }
40
+ }
41
+ return undefined;
42
+ }
43
+ /**
44
+ * Split candidates into tier1 (high relevance) / tier2 (supporting), capped at FINAL_CAP
45
+ * with lowest-score eviction. Note: the score arm uses strict `>` rather than the `>=`
46
+ * boundary — with `>=`, a candidate whose score lands exactly on maxScore/4 (an exact tie,
47
+ * not a rounding artifact — e.g. a token shared by exactly 4 files when another candidate's
48
+ * sole rare token is unique) would spuriously qualify for tier1 instead of tier2.
49
+ */
50
+ export function tierAndCap(candidates, maxScore) {
51
+ const kept = [...candidates].sort((a, b) => b.score - a.score).slice(0, FINAL_CAP);
52
+ const tier1 = kept.filter((c) => c.score > maxScore / 4 || c.rareHits >= 2);
53
+ const tier2 = kept.filter((c) => !tier1.includes(c));
54
+ return { tier1, tier2 };
55
+ }
56
+ /** Deterministic POM pre-scoping. tier1/tier2 both [] ⇒ caller must fall back to the unscoped prompt. */
57
+ export async function selectScopedPoms(opts) {
58
+ const empty = (selectors = 0, pomFiles = 0, discovery = "none") => ({ tier1: [], tier2: [], diagnostics: { selectors, pomFiles, scoped: 0, discovery } });
59
+ let spec = opts.specContent ?? "";
60
+ try {
61
+ spec = await readFile(opts.testFile, "utf8");
62
+ }
63
+ catch { /* use provided specContent */ }
64
+ const root = await findRoot(path.dirname(opts.testFile));
65
+ const testIdAttribute = await readTestIdAttribute(root);
66
+ const extraTestIdAttrs = testIdAttribute && !DEFAULT_TESTID_ATTRS.includes(testIdAttribute) ? [testIdAttribute] : [];
67
+ const selectors = extractSelectors(spec, extraTestIdAttrs);
68
+ if (selectors.length === 0)
69
+ return empty();
70
+ // Conventional dirs/names first. Fall back to a repo-wide selector grep when either:
71
+ // (a) no conventional dirs matched at all (e.g. a `screens/`-only POM layer), or
72
+ // (b) conventional dirs matched but scored zero overlap with this test's selectors — e.g. a
73
+ // `screens/` POM coexists with an unrelated `pages/` POM, and glob-scoping alone would
74
+ // silently miss it AND falsely claim "no repo-wide overlap" when only `pages/` was scored.
75
+ let discovery = "globs";
76
+ let pomFiles = await globPomFiles(root);
77
+ let ranked = pomFiles.length > 0 ? await scorePoms(selectors, pomFiles) : [];
78
+ // Universe for import-expansion recall: conventional files ∪ any discovery-fresh files scanned
79
+ // (even ones that scored 0 selectors themselves) -- so expandImports can still pull in a
80
+ // zero-overlap base/helper class that a discovered POM imports. pomFiles alone (scored matches
81
+ // only) would silently exclude that base class from the expansion universe.
82
+ let expansionUniverse = pomFiles;
83
+ if (pomFiles.length === 0 || ranked.length === 0) {
84
+ const conventional = new Set(pomFiles); // already proved to score 0 (if non-empty) — skip re-reading
85
+ const all = await globAllSourceFiles(root);
86
+ const fresh = all.filter((f) => !conventional.has(f));
87
+ if (fresh.length > DISCOVERY_FILE_CAP) {
88
+ discovery = "skipped-too-large";
89
+ pomFiles = [];
90
+ ranked = [];
91
+ }
92
+ else {
93
+ ranked = await scorePoms(selectors, fresh);
94
+ pomFiles = ranked.map((r) => r.file); // only files containing >=1 selector
95
+ expansionUniverse = [...new Set([...expansionUniverse, ...fresh])];
96
+ discovery = pomFiles.length > 0 ? "selector-grep" : "none";
97
+ }
98
+ }
99
+ if (pomFiles.length === 0 || ranked.length === 0)
100
+ return empty(selectors.length, pomFiles.length, discovery);
101
+ const maxScore = ranked[0].score;
102
+ const selected = ranked
103
+ .filter((r) => r.score >= maxScore / 10 || r.rareHits >= 1)
104
+ .slice(0, SELECTION_CAP)
105
+ .map((r) => r.file);
106
+ const rankedByFile = new Map(ranked.map((r) => [r.file, r]));
107
+ const expanded = await expandImports(selected, expansionUniverse);
108
+ const candidates = expanded.map(({ file, referrer }) => {
109
+ const own = rankedByFile.get(file);
110
+ if (own)
111
+ return { file: own.file, score: own.score, rareHits: own.rareHits, matchedTokens: own.matchedTokens };
112
+ return { file, score: rankedByFile.get(referrer).score, rareHits: 0, matchedTokens: [] };
113
+ });
114
+ const { tier1, tier2 } = tierAndCap(candidates, maxScore);
115
+ return {
116
+ tier1,
117
+ tier2,
118
+ diagnostics: { selectors: selectors.length, pomFiles: pomFiles.length, scoped: tier1.length + tier2.length, discovery },
119
+ };
120
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,239 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { expandImports } from "./import-expansion.js";
6
+ import { FINAL_CAP, selectScopedPoms, tierAndCap } from "./index.js";
7
+ import { globPomFiles } from "./pom-files.js";
8
+ import { RARE_DF } from "./scoring.js";
9
+ const FIX = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__");
10
+ describe("expandImports", () => {
11
+ it("adds one-hop imports/extends but not transitive closure beyond them", async () => {
12
+ const all = await globPomFiles(FIX);
13
+ const widgets = all.find((f) => f.endsWith("widgetsPage.js"));
14
+ const out = (await expandImports([widgets], all)).map((e) => path.basename(e.file));
15
+ expect(out).toContain("widgetsPage.js");
16
+ expect(out).toContain("basePage.js"); // via import { BasePage } from './basePage.js'
17
+ expect(out).not.toContain("loginPage.js"); // not referenced by widgetsPage
18
+ });
19
+ it("tags one-hop additions with their referrer", async () => {
20
+ const all = await globPomFiles(FIX);
21
+ const widgets = all.find((f) => f.endsWith("widgetsPage.js"));
22
+ const out = await expandImports([widgets], all);
23
+ expect(out[0]).toEqual({ file: widgets }); // selected: no referrer
24
+ const base = out.find((e) => e.file.endsWith("basePage.js"));
25
+ expect(base.referrer).toBe(widgets);
26
+ });
27
+ });
28
+ describe("tierAndCap", () => {
29
+ it("evicts by lowest score at FINAL_CAP", () => {
30
+ const mk = (i, score) => ({ file: `f${i}.js`, score, rareHits: 0, matchedTokens: [] });
31
+ const candidates = Array.from({ length: 30 }, (_, i) => mk(i, 30 - i)); // scores 30..1
32
+ const { tier1, tier2 } = tierAndCap(candidates, 30);
33
+ expect(tier1.length + tier2.length).toBe(25);
34
+ const kept = [...tier1, ...tier2].map((p) => p.score);
35
+ expect(Math.min(...kept)).toBe(6); // scores 1..5 evicted
36
+ });
37
+ });
38
+ describe("selectScopedPoms", () => {
39
+ it("scopes to the relevant files for a spec's selectors", async () => {
40
+ const spec = `await page.locator('[data-test="login-sign-in"]').click();
41
+ await page.locator('[data-test="widget-add-btn"]').click();`;
42
+ const { tier1, tier2, diagnostics } = await selectScopedPoms({
43
+ testFile: path.join(FIX, "someTests", "generated.spec.js"),
44
+ specContent: spec,
45
+ });
46
+ const all = [...tier1, ...tier2];
47
+ const names = all.map((f) => path.basename(f.file));
48
+ expect(names).toEqual(expect.arrayContaining(["loginPage.js", "widgetsPage.js", "basePage.js"]));
49
+ expect(names).not.toContain("toastPage.js"); // no matching selector
50
+ expect(all.length).toBeLessThanOrEqual(FINAL_CAP);
51
+ expect(diagnostics.scoped).toBe(all.length);
52
+ });
53
+ it("tiers by score and inherits referrer score for expansion additions", async () => {
54
+ const spec = `page.locator('[data-test="widget-add-btn"]'); page.locator('[data-test="o-toast-message"]');`;
55
+ const { tier1, tier2 } = await selectScopedPoms({
56
+ testFile: path.join(FIX, "someTests", "generated.spec.js"), specContent: spec
57
+ });
58
+ const t1 = tier1.map((p) => path.basename(p.file));
59
+ expect(t1).toEqual(expect.arrayContaining(["widgetsPage.js", "basePage.js"])); // base inherits widgets' score
60
+ const t2 = tier2.map((p) => path.basename(p.file));
61
+ expect(t2).toEqual(expect.arrayContaining(["toastPage.js"])); // weak shared token only
62
+ expect(tier1.find((p) => p.file.endsWith("widgetsPage.js")).matchedTokens).toContain("widget-add-btn");
63
+ });
64
+ it("returns [] when no selectors extract (fallback signal)", async () => {
65
+ const { tier1, tier2 } = await selectScopedPoms({
66
+ testFile: path.join(FIX, "someTests", "generated.spec.js"),
67
+ specContent: "const x = 1;",
68
+ });
69
+ expect(tier1).toEqual([]);
70
+ expect(tier2).toEqual([]);
71
+ });
72
+ it("returns [] when no POM files exist under the root", async () => {
73
+ // Hermetic: anchor the root walk inside a fresh temp dir (empty package.json),
74
+ // so the walk cannot escape to the ambient filesystem.
75
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pom-scope-empty-"));
76
+ try {
77
+ fs.writeFileSync(path.join(dir, "package.json"), "{}");
78
+ const { tier1, tier2 } = await selectScopedPoms({
79
+ testFile: path.join(dir, "sub", "generated.spec.js"),
80
+ specContent: '[data-test="a-b-c"]',
81
+ });
82
+ expect(tier1).toEqual([]);
83
+ expect(tier2).toEqual([]);
84
+ }
85
+ finally {
86
+ fs.rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+ it("excludes files that fail both cutoff arms (low score, no rare hits)", async () => {
90
+ // RARE_DF+1 POMs all share one common token, and richPage also has it, so
91
+ // df = (RARE_DF+1) + 1 = RARE_DF+2 > RARE_DF => the token grants no rareHits to anyone.
92
+ // one POM (richPage) additionally holds 12 distinct rare tokens (df=1 each).
93
+ // The other RARE_DF+1 score 1/df < maxScore/10 with rareHits=0 => excluded.
94
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pom-scope-cutoff-"));
95
+ try {
96
+ fs.writeFileSync(path.join(dir, "package.json"), "{}");
97
+ const pages = path.join(dir, "pages");
98
+ fs.mkdirSync(pages);
99
+ const rareTokens = Array.from({ length: 12 }, (_, i) => `only-here-${i + 1}`);
100
+ for (let n = 1; n <= RARE_DF + 1; n++) {
101
+ fs.writeFileSync(path.join(pages, `commonOnly${n}Page.js`), `export class CommonOnly${n}Page { constructor(page){ this.x = page.locator('[data-test="shared-everywhere-token"]'); } }\n`);
102
+ }
103
+ fs.writeFileSync(path.join(pages, "richPage.js"), `export class RichPage { constructor(page){ this.x = page.locator('[data-test="shared-everywhere-token"]'); ` +
104
+ rareTokens.map((t, i) => `this.r${i} = page.locator('[data-test="${t}"]');`).join(" ") +
105
+ ` } }\n`);
106
+ const specContent = ["shared-everywhere-token", ...rareTokens]
107
+ .map((t) => `page.locator('[data-test="${t}"]');`)
108
+ .join("\n");
109
+ const { tier1, tier2, diagnostics } = await selectScopedPoms({
110
+ testFile: path.join(dir, "tests", "generated.spec.js"),
111
+ specContent,
112
+ });
113
+ const all = [...tier1, ...tier2];
114
+ expect(all.map((f) => path.basename(f.file))).toEqual(["richPage.js"]);
115
+ expect(tier1.map((f) => path.basename(f.file))).toEqual(["richPage.js"]); // lone survivor lands in tier1
116
+ expect(diagnostics.pomFiles).toBe(RARE_DF + 2);
117
+ }
118
+ finally {
119
+ fs.rmSync(dir, { recursive: true, force: true });
120
+ }
121
+ });
122
+ it("reads testIdAttribute from playwright.config and scopes by it", async () => {
123
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pom-scope-tia-"));
124
+ try {
125
+ fs.writeFileSync(path.join(dir, "package.json"), "{}");
126
+ fs.writeFileSync(path.join(dir, "playwright.config.ts"), `export default { use: { testIdAttribute: "data-zzz" } };`);
127
+ const pages = path.join(dir, "pages");
128
+ fs.mkdirSync(pages);
129
+ fs.writeFileSync(path.join(pages, "fooPage.js"), `export class FooPage { constructor(page){ this.x = page.locator('[data-zzz="foo-unique-btn"]'); } }\n`);
130
+ const { tier1, tier2 } = await selectScopedPoms({
131
+ testFile: path.join(dir, "t", "g.spec.js"),
132
+ specContent: `page.locator('[data-zzz="foo-unique-btn"]');`,
133
+ });
134
+ expect([...tier1, ...tier2].map((p) => path.basename(p.file))).toContain("fooPage.js");
135
+ }
136
+ finally {
137
+ fs.rmSync(dir, { recursive: true, force: true });
138
+ }
139
+ });
140
+ it("discovers POMs outside conventional dirs via selector grep", async () => {
141
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pom-scope-disc-"));
142
+ try {
143
+ fs.writeFileSync(path.join(dir, "package.json"), "{}");
144
+ const screens = path.join(dir, "screens");
145
+ fs.mkdirSync(screens); // NOT a conventional POM dir
146
+ fs.writeFileSync(path.join(screens, "checkoutScreen.js"), `export class CheckoutScreen { constructor(page){ this.pay = page.locator('[data-test="pay-now-btn"]'); } }\n`);
147
+ const { tier1, tier2, diagnostics } = await selectScopedPoms({
148
+ testFile: path.join(dir, "t", "g.spec.js"),
149
+ specContent: `page.locator('[data-test="pay-now-btn"]');`,
150
+ });
151
+ expect([...tier1, ...tier2].map((p) => path.basename(p.file))).toContain("checkoutScreen.js");
152
+ expect(diagnostics.discovery).toBe("selector-grep");
153
+ }
154
+ finally {
155
+ fs.rmSync(dir, { recursive: true, force: true });
156
+ }
157
+ });
158
+ it("expands imports into a discovery-fresh base class even when it scored zero selectors itself", async () => {
159
+ // checkoutScreen.js is the only file scoring the spec's selector, but it extends baseScreen.js
160
+ // which shares no selector tokens with the spec at all. baseScreen.js must still be pulled in
161
+ // via one-hop import expansion -- the expansion universe has to include ALL discovery-fresh
162
+ // files (scored or not), not just the ones that individually matched a selector.
163
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pom-scope-expand-univ-"));
164
+ try {
165
+ fs.writeFileSync(path.join(dir, "package.json"), "{}");
166
+ const screens = path.join(dir, "screens");
167
+ fs.mkdirSync(screens); // NOT a conventional POM dir
168
+ fs.writeFileSync(path.join(screens, "baseScreen.js"), `export class BaseScreen { constructor(page){ this.page = page; } }\n`);
169
+ fs.writeFileSync(path.join(screens, "checkoutScreen.js"), `import { BaseScreen } from './baseScreen.js';\nexport class CheckoutScreen extends BaseScreen { constructor(page){ super(page); this.pay = page.locator('[data-test="pay-now-btn"]'); } }\n`);
170
+ const { tier1, tier2 } = await selectScopedPoms({
171
+ testFile: path.join(dir, "t", "g.spec.js"),
172
+ specContent: `page.locator('[data-test="pay-now-btn"]');`,
173
+ });
174
+ const names = [...tier1, ...tier2].map((f) => path.basename(f.file));
175
+ expect(names).toContain("checkoutScreen.js");
176
+ expect(names).toContain("baseScreen.js");
177
+ }
178
+ finally {
179
+ fs.rmSync(dir, { recursive: true, force: true });
180
+ }
181
+ });
182
+ it("reports a clean no-overlap scan distinctly from machinery failure", async () => {
183
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pom-scope-noov-"));
184
+ try {
185
+ fs.writeFileSync(path.join(dir, "package.json"), "{}");
186
+ fs.mkdirSync(path.join(dir, "src"));
187
+ fs.writeFileSync(path.join(dir, "src", "app.js"), `export const x = 1;\n`);
188
+ const r = await selectScopedPoms({ testFile: path.join(dir, "t", "g.spec.js"), specContent: `page.locator('[data-test="zzz-never"]');` });
189
+ expect(r.tier1).toEqual([]);
190
+ expect(r.tier2).toEqual([]);
191
+ expect(r.diagnostics.discovery).toBe("none");
192
+ }
193
+ finally {
194
+ fs.rmSync(dir, { recursive: true, force: true });
195
+ }
196
+ });
197
+ it("does not discover a Cypress spec file (*.cy.js) as a fallback POM", async () => {
198
+ // Cypress specs are full of data-cy selectors (a member of DEFAULT_TESTID_ATTRS), so without
199
+ // an exclusion the selector-grep fallback would score and select the test file itself as a POM.
200
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pom-scope-cy-"));
201
+ try {
202
+ fs.writeFileSync(path.join(dir, "package.json"), "{}");
203
+ const e2e = path.join(dir, "cypress", "e2e");
204
+ fs.mkdirSync(e2e, { recursive: true });
205
+ fs.writeFileSync(path.join(e2e, "checkout.cy.js"), `it("pays", () => { cy.get('[data-cy="pay-now"]').click(); });\n`);
206
+ const { tier1, tier2, diagnostics } = await selectScopedPoms({
207
+ testFile: path.join(dir, "t", "g.spec.js"),
208
+ specContent: `page.locator('[data-cy="pay-now"]');`,
209
+ });
210
+ expect(tier1).toEqual([]);
211
+ expect(tier2).toEqual([]);
212
+ expect(diagnostics.discovery).toBe("none");
213
+ }
214
+ finally {
215
+ fs.rmSync(dir, { recursive: true, force: true });
216
+ }
217
+ });
218
+ it("falls through to repo-wide discovery when conventional dirs match nothing", async () => {
219
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pom-scope-coex-"));
220
+ try {
221
+ fs.writeFileSync(path.join(dir, "package.json"), "{}");
222
+ const pages = path.join(dir, "pages");
223
+ fs.mkdirSync(pages);
224
+ fs.writeFileSync(path.join(pages, "otherPage.js"), `export class OtherPage { constructor(page){ this.x = page.locator('[data-test="unrelated-btn"]'); } }\n`);
225
+ const screens = path.join(dir, "screens");
226
+ fs.mkdirSync(screens);
227
+ fs.writeFileSync(path.join(screens, "checkoutScreen.js"), `export class CheckoutScreen { constructor(page){ this.pay = page.locator('[data-test="pay-now-btn"]'); } }\n`);
228
+ const { tier1, tier2, diagnostics } = await selectScopedPoms({
229
+ testFile: path.join(dir, "t", "g.spec.js"),
230
+ specContent: `page.locator('[data-test="pay-now-btn"]');`,
231
+ });
232
+ expect([...tier1, ...tier2].map((p) => path.basename(p.file))).toContain("checkoutScreen.js");
233
+ expect(diagnostics.discovery).toBe("selector-grep");
234
+ }
235
+ finally {
236
+ fs.rmSync(dir, { recursive: true, force: true });
237
+ }
238
+ });
239
+ });
@@ -0,0 +1,3 @@
1
+ export declare function globPomFiles(root: string): Promise<string[]>;
2
+ /** Fallback discovery for repos whose POM layer doesn't match the conventional glob patterns. */
3
+ export declare function globAllSourceFiles(root: string): Promise<string[]>;
@@ -0,0 +1,48 @@
1
+ import fg from "fast-glob";
2
+ import * as path from "path";
3
+ /** Mirrors the prompt's STEP 1 globs; exclusion is by BASENAME only (see fix 8bded34a). */
4
+ const POM_GLOBS = [
5
+ "**/pageobjects/**/*.{ts,js}",
6
+ "**/page-objects/**/*.{ts,js}",
7
+ "**/pages/**/*.{ts,js}",
8
+ "**/*.page.{ts,js}",
9
+ "**/*Page.{ts,js}",
10
+ "**/pom/**/*.{ts,js}",
11
+ ];
12
+ const EXCLUDED_BASENAME = /\.(spec|test|cy)\.(ts|js|tsx|jsx|mjs|cjs)$/;
13
+ const ALL_SOURCE_GLOBS = ["**/*.{ts,js,tsx,jsx,mjs,cjs}"];
14
+ const ALL_SOURCE_IGNORE = [
15
+ "**/node_modules/**",
16
+ "**/dist/**",
17
+ "**/build/**",
18
+ "**/coverage/**",
19
+ "**/.git/**",
20
+ "**/vendor/**",
21
+ "**/cypress/**",
22
+ "**/__tests__/**",
23
+ ];
24
+ export async function globPomFiles(root) {
25
+ const matches = await fg(POM_GLOBS, {
26
+ cwd: root,
27
+ absolute: true,
28
+ ignore: ["**/node_modules/**"],
29
+ suppressErrors: true,
30
+ followSymbolicLinks: false,
31
+ });
32
+ return [...new Set(matches)]
33
+ .filter((f) => !EXCLUDED_BASENAME.test(path.basename(f)))
34
+ .sort();
35
+ }
36
+ /** Fallback discovery for repos whose POM layer doesn't match the conventional glob patterns. */
37
+ export async function globAllSourceFiles(root) {
38
+ const matches = await fg(ALL_SOURCE_GLOBS, {
39
+ cwd: root,
40
+ absolute: true,
41
+ ignore: ALL_SOURCE_IGNORE,
42
+ suppressErrors: true,
43
+ followSymbolicLinks: false,
44
+ });
45
+ return [...new Set(matches)]
46
+ .filter((f) => !EXCLUDED_BASENAME.test(path.basename(f)))
47
+ .sort();
48
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { globPomFiles } from "./pom-files.js";
6
+ const FIX = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__");
7
+ describe("globPomFiles", () => {
8
+ it("finds POM files, excludes node_modules and .spec/.test basenames, keeps 'spec' substrings", async () => {
9
+ const files = await globPomFiles(FIX);
10
+ expect(files.every((f) => path.isAbsolute(f))).toBe(true);
11
+ const sorted = [...files].sort();
12
+ expect(files).toEqual(sorted);
13
+ const names = files.map((f) => path.basename(f));
14
+ expect(names).toEqual(expect.arrayContaining([
15
+ "loginPage.js", "widgetsPage.js", "toastPage.js", "basePage.js", "page-manager.js", "inspectorPage.js",
16
+ ]));
17
+ expect(names).not.toContain("loginPage.spec.js");
18
+ expect(names).not.toContain("somePage.js");
19
+ });
20
+ it("returns [] for a root with no POM files", async () => {
21
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), "pom-empty-"));
22
+ try {
23
+ expect(await globPomFiles(empty)).toEqual([]);
24
+ }
25
+ finally {
26
+ fs.rmSync(empty, { recursive: true, force: true });
27
+ }
28
+ });
29
+ });
@@ -0,0 +1,20 @@
1
+ export declare const RARE_DF = 5;
2
+ export type PomScore = {
3
+ file: string;
4
+ score: number;
5
+ hits: number;
6
+ rareHits: number;
7
+ matchedTokens: string[];
8
+ };
9
+ /**
10
+ * IDF-style scoring: each selector contributes 1/df to every file containing it
11
+ * (df = number of files containing the selector). Rare selectors dominate;
12
+ * ubiquitous ones (global toasts, shared menus) contribute ~0.
13
+ * Backslashes are stripped on both sides so `menu-link-\/x` matches `menu-link-/x`.
14
+ *
15
+ * RARE_DF=5 (adjusted from 3): family-shared iframe/element ids can legitimately
16
+ * span several files in a POM family — e.g. AssetForce's `#application-new-applied`
17
+ * spans 4 of 63 POMs — and should still grant rare-hit selection. df<=5 is the
18
+ * evidence-adjusted best-guess threshold for "rare enough to count".
19
+ */
20
+ export declare function scorePoms(selectors: string[], files: string[]): Promise<PomScore[]>;
@@ -0,0 +1,45 @@
1
+ import { readFile } from "fs/promises";
2
+ export const RARE_DF = 5;
3
+ /**
4
+ * IDF-style scoring: each selector contributes 1/df to every file containing it
5
+ * (df = number of files containing the selector). Rare selectors dominate;
6
+ * ubiquitous ones (global toasts, shared menus) contribute ~0.
7
+ * Backslashes are stripped on both sides so `menu-link-\/x` matches `menu-link-/x`.
8
+ *
9
+ * RARE_DF=5 (adjusted from 3): family-shared iframe/element ids can legitimately
10
+ * span several files in a POM family — e.g. AssetForce's `#application-new-applied`
11
+ * spans 4 of 63 POMs — and should still grant rare-hit selection. df<=5 is the
12
+ * evidence-adjusted best-guess threshold for "rare enough to count".
13
+ */
14
+ export async function scorePoms(selectors, files) {
15
+ if (selectors.length === 0 || files.length === 0)
16
+ return [];
17
+ const contents = new Map();
18
+ await Promise.all(files.map(async (f) => {
19
+ try {
20
+ contents.set(f, (await readFile(f, "utf8")).replace(/\\/g, ""));
21
+ }
22
+ catch { /* unreadable file: skip */ }
23
+ }));
24
+ const fileMatches = new Map(); // selector -> files containing it
25
+ for (const sel of selectors) {
26
+ const needle = sel.replace(/\\/g, "");
27
+ const matched = [...contents.entries()].filter(([, c]) => c.includes(needle)).map(([f]) => f);
28
+ if (matched.length > 0)
29
+ fileMatches.set(sel, matched);
30
+ }
31
+ const scores = new Map();
32
+ for (const [sel, matched] of fileMatches) {
33
+ const df = matched.length;
34
+ for (const f of matched) {
35
+ const s = scores.get(f) ?? { file: f, score: 0, hits: 0, rareHits: 0, matchedTokens: [] };
36
+ s.score += 1 / df;
37
+ s.hits += 1;
38
+ if (df <= RARE_DF)
39
+ s.rareHits += 1;
40
+ s.matchedTokens.push(sel.replace(/\\/g, ""));
41
+ scores.set(f, s);
42
+ }
43
+ }
44
+ return [...scores.values()].sort((a, b) => b.score - a.score);
45
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import * as path from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { globPomFiles } from "./pom-files.js";
4
+ import { scorePoms } from "./scoring.js";
5
+ const FIX = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__");
6
+ describe("scorePoms", () => {
7
+ it("ranks rare-selector files high; ubiquitous selectors contribute ~nothing", async () => {
8
+ const files = await globPomFiles(FIX);
9
+ // widget-add-btn appears only in widgetsPage (df=1) → strong.
10
+ // o-toast-message appears in 3 files (df=3) → weak-ish but still rare in this tiny fixture;
11
+ // login-sign-in only in loginPage (df=1) → strong.
12
+ const ranked = await scorePoms(["widget-add-btn", "login-sign-in", "o-toast-message"], files);
13
+ const names = ranked.map((r) => path.basename(r.file));
14
+ expect(names[0]).toBe("widgetsPage.js"); // 1/1 + 1/3
15
+ expect(names[1]).toBe("loginPage.js"); // 1/1 + 1/3
16
+ const toastOnly = ranked.find((r) => path.basename(r.file) === "toastPage.js");
17
+ expect(toastOnly.score).toBeLessThan(0.5); // only the shared toast selector
18
+ expect(toastOnly.rareHits).toBeGreaterThanOrEqual(0);
19
+ });
20
+ it("matches through backslash escaping", async () => {
21
+ const files = await globPomFiles(FIX);
22
+ // fixture loginPage has plain value; simulate escaped token from spec
23
+ const ranked = await scorePoms(["login-sign-in"], files);
24
+ expect(path.basename(ranked[0].file)).toBe("loginPage.js");
25
+ expect(ranked[0].hits).toBe(1);
26
+ });
27
+ it("returns empty for no selectors or no files", async () => {
28
+ expect(await scorePoms([], ["x"])).toEqual([]);
29
+ expect(await scorePoms(["a-b-c"], [])).toEqual([]);
30
+ });
31
+ it("records which tokens matched each file", async () => {
32
+ const files = await globPomFiles(FIX);
33
+ const ranked = await scorePoms(["widget-add-btn", "o-toast-message"], files);
34
+ const widgets = ranked.find((r) => path.basename(r.file) === "widgetsPage.js");
35
+ expect(widgets.matchedTokens).toEqual(["widget-add-btn", "o-toast-message"]);
36
+ const toast = ranked.find((r) => path.basename(r.file) === "toastPage.js");
37
+ expect(toast.matchedTokens).toEqual(["o-toast-message"]);
38
+ });
39
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Extracts selector *value tokens* from a generated test's source. Tokens are
3
+ * the join key between the raw spec and the POM layer: we grep POM files for
4
+ * these strings to find which page objects cover the test's actions.
5
+ */
6
+ export declare const DEFAULT_TESTID_ATTRS: string[];
7
+ export declare function extractSelectors(specContent: string, extraTestIdAttrs?: string[]): string[];