@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.
- package/build/index.js +2 -1
- package/build/prompts/code-reuse.d.ts +7 -1
- package/build/prompts/code-reuse.js +8 -5
- package/build/prompts/code-reuse.test.d.ts +1 -0
- package/build/prompts/code-reuse.test.js +62 -0
- package/build/prompts/pom-aware-code-reuse.d.ts +6 -1
- package/build/prompts/pom-aware-code-reuse.js +100 -53
- package/build/prompts/pom-aware-code-reuse.test.d.ts +1 -0
- package/build/prompts/pom-aware-code-reuse.test.js +11 -0
- package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +4 -2
- package/build/prompts/test-recommendation/diffExecutionPlan.js +11 -65
- package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +2 -2
- package/build/prompts/test-recommendation/recommendationSections.js +5 -2
- package/build/prompts/test-recommendation/scopeAssessment.js +1 -1
- package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +26 -1
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +68 -56
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +48 -11
- package/build/prompts/testbot/testbot-prompts.d.ts +1 -1
- package/build/prompts/testbot/testbot-prompts.js +67 -21
- package/build/prompts/testbot/testbot-prompts.test.js +44 -0
- package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +7 -0
- package/build/recommendation/budgeters/diversityBalancedBudgeter.js +71 -0
- package/build/recommendation/budgeters/diversityBalancedBudgeter.test.d.ts +1 -0
- package/build/recommendation/budgeters/diversityBalancedBudgeter.test.js +75 -0
- package/build/recommendation/budgeters/fixedNBudgeter.d.ts +7 -0
- package/build/recommendation/budgeters/fixedNBudgeter.js +11 -0
- package/build/recommendation/budgeters/fixedNBudgeter.test.d.ts +1 -0
- package/build/recommendation/budgeters/fixedNBudgeter.test.js +66 -0
- package/build/recommendation/budgeters/shared.d.ts +19 -0
- package/build/recommendation/budgeters/shared.js +66 -0
- package/build/recommendation/discriminators.d.ts +31 -0
- package/build/recommendation/discriminators.js +355 -0
- package/build/recommendation/discriminators.test.d.ts +1 -0
- package/build/recommendation/discriminators.test.js +324 -0
- package/build/recommendation/diversity.d.ts +47 -0
- package/build/recommendation/diversity.js +101 -0
- package/build/recommendation/diversity.test.d.ts +1 -0
- package/build/recommendation/diversity.test.js +77 -0
- package/build/recommendation/planRanker.d.ts +50 -0
- package/build/recommendation/planRanker.js +67 -0
- package/build/recommendation/planRanker.test.d.ts +1 -0
- package/build/recommendation/planRanker.test.js +110 -0
- package/build/recommendation/testFixtures.d.ts +25 -0
- package/build/recommendation/testFixtures.js +45 -0
- package/build/resources/testbotResource.js +4 -1
- package/build/services/ScenarioGenerationService.d.ts +5 -0
- package/build/services/ScenarioGenerationService.js +16 -1
- package/build/services/ScenarioGenerationService.test.js +44 -0
- package/build/services/TestExecutionService.d.ts +15 -1
- package/build/services/TestExecutionService.js +210 -55
- package/build/services/TestExecutionService.test.js +397 -0
- package/build/services/TestGenerationService.js +19 -1
- package/build/services/TestGenerationService.test.js +58 -0
- package/build/tool-phases.js +1 -0
- package/build/toolNames.d.ts +19 -0
- package/build/toolNames.js +19 -0
- package/build/tools/code-refactor/codeReuseTool.d.ts +7 -0
- package/build/tools/code-refactor/codeReuseTool.js +130 -4
- package/build/tools/code-refactor/codeReuseTool.test.d.ts +1 -0
- package/build/tools/code-refactor/codeReuseTool.test.js +290 -0
- package/build/tools/executeSkyrampTestTool.js +8 -2
- package/build/tools/generate-tests/generateBatchScenarioRestTool.d.ts +6 -1
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +110 -17
- package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +147 -0
- package/build/tools/generate-tests/generateContractRestTool.js +11 -1
- package/build/tools/generate-tests/generateIntegrationRestTool.js +24 -1
- package/build/tools/generate-tests/generateIntegrationRestTool.test.d.ts +1 -0
- package/build/tools/generate-tests/generateIntegrationRestTool.test.js +159 -0
- package/build/tools/generate-tests/planGuard.d.ts +13 -0
- package/build/tools/generate-tests/planGuard.js +78 -0
- package/build/tools/generate-tests/planGuard.test.d.ts +1 -0
- package/build/tools/generate-tests/planGuard.test.js +185 -0
- package/build/tools/generate-tests/scenarioFileIdentity.d.ts +10 -0
- package/build/tools/generate-tests/scenarioFileIdentity.js +46 -0
- package/build/tools/generate-tests/scenarioLint.d.ts +30 -0
- package/build/tools/generate-tests/scenarioLint.js +150 -0
- package/build/tools/generate-tests/scenarioLint.test.d.ts +1 -0
- package/build/tools/generate-tests/scenarioLint.test.js +100 -0
- package/build/tools/submitReportTool.js +78 -0
- package/build/tools/submitReportTool.test.js +255 -0
- package/build/tools/test-management/analyzeChangesTool.js +55 -2
- package/build/tools/test-management/analyzeChangesTool.test.js +12 -0
- package/build/tools/test-management/index.d.ts +1 -0
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/registerTestPlanTool.d.ts +2 -0
- package/build/tools/test-management/registerTestPlanTool.js +329 -0
- package/build/tools/test-management/registerTestPlanTool.test.d.ts +1 -0
- package/build/tools/test-management/registerTestPlanTool.test.js +296 -0
- package/build/types/Recommendation.d.ts +97 -0
- package/build/types/Recommendation.js +48 -0
- package/build/types/RepositoryAnalysis.d.ts +14 -14
- package/build/types/TestExecution.d.ts +2 -0
- package/build/types/TestRecommendation.d.ts +12 -1
- package/build/types/TestRecommendation.js +26 -11
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.d.ts +47 -0
- package/build/utils/docker.test.js +1 -1
- package/build/utils/planMatchKeys.d.ts +61 -0
- package/build/utils/planMatchKeys.js +125 -0
- package/build/utils/pom-scope/import-expansion.d.ts +5 -0
- package/build/utils/pom-scope/import-expansion.js +32 -0
- package/build/utils/pom-scope/index.d.ts +39 -0
- package/build/utils/pom-scope/index.js +120 -0
- package/build/utils/pom-scope/index.test.d.ts +1 -0
- package/build/utils/pom-scope/index.test.js +239 -0
- package/build/utils/pom-scope/pom-files.d.ts +3 -0
- package/build/utils/pom-scope/pom-files.js +48 -0
- package/build/utils/pom-scope/pom-files.test.d.ts +1 -0
- package/build/utils/pom-scope/pom-files.test.js +29 -0
- package/build/utils/pom-scope/scoring.d.ts +20 -0
- package/build/utils/pom-scope/scoring.js +45 -0
- package/build/utils/pom-scope/scoring.test.d.ts +1 -0
- package/build/utils/pom-scope/scoring.test.js +39 -0
- package/build/utils/pom-scope/selector-extractor.d.ts +7 -0
- package/build/utils/pom-scope/selector-extractor.js +57 -0
- package/build/utils/pom-scope/selector-extractor.test.d.ts +1 -0
- package/build/utils/pom-scope/selector-extractor.test.js +67 -0
- package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.d.ts +5 -0
- package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.js +5 -0
- package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.d.ts +5 -0
- package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.js +9 -0
- package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.d.ts +4 -0
- package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.js +4 -0
- package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.d.ts +4 -0
- package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.js +4 -0
- package/build/utils/pom-verify/bindings.d.ts +19 -0
- package/build/utils/pom-verify/bindings.js +161 -0
- package/build/utils/pom-verify/bindings.test.d.ts +1 -0
- package/build/utils/pom-verify/bindings.test.js +164 -0
- package/build/utils/pom-verify/calls.d.ts +16 -0
- package/build/utils/pom-verify/calls.js +42 -0
- package/build/utils/pom-verify/calls.test.d.ts +1 -0
- package/build/utils/pom-verify/calls.test.js +61 -0
- package/build/utils/pom-verify/index.d.ts +4 -0
- package/build/utils/pom-verify/index.js +4 -0
- package/build/utils/pom-verify/resolve.d.ts +7 -0
- package/build/utils/pom-verify/resolve.js +27 -0
- package/build/utils/pom-verify/resolve.test.d.ts +1 -0
- package/build/utils/pom-verify/resolve.test.js +68 -0
- package/build/utils/pom-verify/strip.d.ts +9 -0
- package/build/utils/pom-verify/strip.js +89 -0
- package/build/utils/pom-verify/verify.d.ts +14 -0
- package/build/utils/pom-verify/verify.js +158 -0
- package/build/utils/pom-verify/verify.test.d.ts +1 -0
- package/build/utils/pom-verify/verify.test.js +325 -0
- package/build/utils/reportVerification.d.ts +61 -0
- package/build/utils/reportVerification.js +104 -0
- package/build/utils/reportVerification.test.d.ts +1 -0
- package/build/utils/reportVerification.test.js +185 -0
- package/build/utils/scenarioDrafting.js +5 -5
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.d.ts +9 -1
- package/build/utils/workspaceAuth.js +25 -5
- package/build/utils/workspaceAuth.test.js +48 -0
- package/build/workspace/workspace.d.ts +20 -0
- package/build/workspace/workspace.js +4 -0
- package/build/workspace/workspace.test.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +77 -8
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/utils/isomorphic/volatileDate.js +101 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-aszq5EdG.js → codeMirrorModule-Bzd72-bG.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-BxS7Jm4s.js → defaultSettingsView-DzxTioTK.js} +101 -101
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.D4JTTy4R.js → index.BGc30U3S.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.DaRMQKOI.js → uiMode.IaDrb29A.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/utils/isomorphic/volatileDate.ts +131 -0
- package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
- package/node_modules/playwright/package.json +1 -1
- package/package.json +3 -3
- 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,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[];
|