@ivorycanvas/qamap 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +20 -16
- package/dist/cli.js +33 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain-language.d.ts +1 -1
- package/dist/domain-language.js +105 -10
- package/dist/domain-language.js.map +1 -1
- package/dist/e2e.d.ts +1 -0
- package/dist/e2e.js +316 -58
- package/dist/e2e.js.map +1 -1
- package/dist/import-graph.d.ts +17 -0
- package/dist/import-graph.js +308 -0
- package/dist/import-graph.js.map +1 -0
- package/dist/manifest.js +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/qa.js +58 -2
- package/dist/qa.js.map +1 -1
- package/dist/test-plan.d.ts +7 -0
- package/dist/test-plan.js +47 -0
- package/dist/test-plan.js.map +1 -1
- package/dist/verify.js +19 -1
- package/dist/verify.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adoption.md +2 -2
- package/docs/assets/qamap-30s-demo.gif +0 -0
- package/docs/benchmarking.md +31 -0
- package/docs/configuration.md +12 -12
- package/docs/e2e-output-examples.md +40 -40
- package/docs/eval.md +1 -1
- package/docs/github-action.md +2 -2
- package/docs/manifest.md +13 -13
- package/docs/release-validation.md +34 -5
- package/docs/roadmap.md +0 -1
- package/docs/verify.md +1 -1
- package/package.json +3 -2
- package/skills/qamap-pr-qa/SKILL.md +1 -1
package/dist/e2e.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
3
4
|
import { buildDomainLanguageSummary } from "./domain-language.js";
|
|
4
5
|
import { defaultDomainManifestPath, loadDomainManifest, matchDomains } from "./domains.js";
|
|
5
6
|
import { collectProjectFiles } from "./fs.js";
|
|
7
|
+
import { buildReverseImportIndex, expandChangedFilesWithImporters, findImportingSurfaces } from "./import-graph.js";
|
|
6
8
|
import { loadCoreFlowManifest, matchCoreFlows } from "./flows.js";
|
|
7
9
|
import { loadVerificationManifest, matchVerificationManifest } from "./manifest.js";
|
|
8
10
|
import { collectTestSuiteInventory, evaluateFlowCoverageEvidence, summarizeTestSuiteInventory, } from "./test-evidence.js";
|
|
9
|
-
import { generateTestPlan } from "./test-plan.js";
|
|
11
|
+
import { collectAddedDiffText, generateTestPlan } from "./test-plan.js";
|
|
10
12
|
import { TOOL_NAME, VERSION } from "./version.js";
|
|
11
13
|
const maxFilesPerFlow = 8;
|
|
12
14
|
const workspacePackageSearchLimit = 200;
|
|
@@ -34,14 +36,21 @@ export async function generateE2ePlan(rootInput, options = {}) {
|
|
|
34
36
|
const coreFlowRoot = testPlan.workspaceRoot ?? root;
|
|
35
37
|
const coreFlowManifest = await loadCoreFlowManifest(coreFlowRoot);
|
|
36
38
|
const coreFlowChangedFiles = toCoreFlowChangedFiles(testPlan.changedFiles, root, coreFlowRoot);
|
|
37
|
-
const
|
|
39
|
+
const matchableChangedFiles = await expandChangedFilesForMatching(coreFlowRoot, coreFlowChangedFiles);
|
|
40
|
+
const coreFlows = matchCoreFlows(coreFlowManifest, matchableChangedFiles);
|
|
38
41
|
const domainManifest = await loadDomainManifest(coreFlowRoot);
|
|
39
|
-
const domains = matchDomains(domainManifest,
|
|
42
|
+
const domains = matchDomains(domainManifest, matchableChangedFiles);
|
|
40
43
|
const verificationManifest = await loadVerificationManifest(coreFlowRoot, { manifestPath: options.manifestPath });
|
|
41
|
-
const verificationManifestMatches = matchVerificationManifest(verificationManifest,
|
|
42
|
-
const
|
|
44
|
+
const verificationManifestMatches = matchVerificationManifest(verificationManifest, matchableChangedFiles);
|
|
45
|
+
const addedDiffText = await collectAddedDiffText(root, {
|
|
46
|
+
base: testPlan.base,
|
|
47
|
+
head: testPlan.head,
|
|
48
|
+
workspaceRoot: testPlan.workspaceRoot,
|
|
49
|
+
includeWorkingTree: options.includeWorkingTree,
|
|
50
|
+
});
|
|
51
|
+
const domainLanguage = await buildDomainLanguageSummary(root, testPlan.changedFiles, coreFlows, domains, addedDiffText);
|
|
43
52
|
const workspaceTargets = await buildWorkspaceTargets(root, testPlan);
|
|
44
|
-
const flows = await buildFlows(root, testPlan.changedFiles, recommendedRunner.name, project.type, testSuiteInventory, domainLanguage);
|
|
53
|
+
const flows = await buildFlows(root, testPlan.changedFiles, recommendedRunner.name, project.type, testSuiteInventory, domainLanguage, addedDiffText);
|
|
45
54
|
const testSuite = summarizeTestSuiteInventory(testSuiteInventory);
|
|
46
55
|
const missingTestability = uniqueStrings([
|
|
47
56
|
...flows.flatMap((flow) => flow.missingTestability),
|
|
@@ -105,6 +114,12 @@ export async function generateE2ePlan(rootInput, options = {}) {
|
|
|
105
114
|
export async function generateE2eDraft(rootInput, options = {}) {
|
|
106
115
|
const root = path.resolve(rootInput);
|
|
107
116
|
const plan = await generateE2ePlan(root, options);
|
|
117
|
+
const addedDiffText = await collectAddedDiffText(root, {
|
|
118
|
+
base: plan.base,
|
|
119
|
+
head: plan.head,
|
|
120
|
+
workspaceRoot: plan.workspaceRoot,
|
|
121
|
+
includeWorkingTree: options.includeWorkingTree,
|
|
122
|
+
});
|
|
108
123
|
const runner = plan.recommendedRunner.name;
|
|
109
124
|
const outputDirectory = path.resolve(root, options.output ?? defaultDraftOutputDirectory(runner));
|
|
110
125
|
const draftLimit = options.maxDrafts && options.maxDrafts > 0 ? Math.floor(options.maxDrafts) : undefined;
|
|
@@ -121,7 +136,7 @@ export async function generateE2eDraft(rootInput, options = {}) {
|
|
|
121
136
|
const promotionGuidance = buildDraftPromotionGuidance(flow);
|
|
122
137
|
const fileAlreadyExists = await exists(filePath);
|
|
123
138
|
const shouldSkip = fileAlreadyExists && !options.force && !dryRun;
|
|
124
|
-
const content = shouldSkip ? ((await readTextIfExists(filePath)) ?? "") : draftContentForFlow(plan, flow, runner);
|
|
139
|
+
const content = shouldSkip ? ((await readTextIfExists(filePath)) ?? "") : draftContentForFlow(plan, flow, runner, addedDiffText);
|
|
125
140
|
const todoCount = countTodos(content);
|
|
126
141
|
const selfCheck = evaluateDraftSelfCheck(plan, flow, runner, content, todoCount);
|
|
127
142
|
const actionItems = buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck);
|
|
@@ -1214,7 +1229,7 @@ function inferFlowActor(flow) {
|
|
|
1214
1229
|
if (/\b(auth|login|logout|session|account|profile|permission)\b/.test(haystack)) {
|
|
1215
1230
|
return "Signed-in user or guest";
|
|
1216
1231
|
}
|
|
1217
|
-
if (/\b(checkout|purchase|payment|order|cart|offer|subscription|membership|billing)\b/.test(haystack)) {
|
|
1232
|
+
if (/\b(checkout|purchase|payment|order|cart|offer|listing|subscription|membership|billing)\b/.test(haystack)) {
|
|
1218
1233
|
return "Customer";
|
|
1219
1234
|
}
|
|
1220
1235
|
if (hasUserFacingEntrypointOrFile(flow)) {
|
|
@@ -1929,6 +1944,27 @@ export function formatMarkdownE2eSetup(result) {
|
|
|
1929
1944
|
}
|
|
1930
1945
|
return lines.join("\n");
|
|
1931
1946
|
}
|
|
1947
|
+
const frameworkSignalDependencies = [
|
|
1948
|
+
"expo",
|
|
1949
|
+
"react-native",
|
|
1950
|
+
"@playwright/test",
|
|
1951
|
+
"playwright",
|
|
1952
|
+
"@angular/core",
|
|
1953
|
+
"@remix-run/react",
|
|
1954
|
+
"astro",
|
|
1955
|
+
"next",
|
|
1956
|
+
"nuxt",
|
|
1957
|
+
"react-dom",
|
|
1958
|
+
"react-router-dom",
|
|
1959
|
+
"svelte",
|
|
1960
|
+
"vue",
|
|
1961
|
+
"vite",
|
|
1962
|
+
"@nestjs/core",
|
|
1963
|
+
"express",
|
|
1964
|
+
"fastify",
|
|
1965
|
+
"koa",
|
|
1966
|
+
"hono",
|
|
1967
|
+
];
|
|
1932
1968
|
async function detectProjectProfile(root, workspaceRoot) {
|
|
1933
1969
|
const profileRoots = profileSearchRoots(root, workspaceRoot);
|
|
1934
1970
|
const packageJson = await readPackageJson(root);
|
|
@@ -1940,6 +1976,20 @@ async function detectProjectProfile(root, workspaceRoot) {
|
|
|
1940
1976
|
...(packageJson?.devDependencies ?? {}),
|
|
1941
1977
|
};
|
|
1942
1978
|
const evidence = [];
|
|
1979
|
+
const rootHasFrameworkSignal = frameworkSignalDependencies.some((dependency) => dependency in dependencies);
|
|
1980
|
+
if (!rootHasFrameworkSignal) {
|
|
1981
|
+
const memberDependencies = await collectWorkspaceMemberDependencies(root, packageJson);
|
|
1982
|
+
for (const member of memberDependencies) {
|
|
1983
|
+
for (const [dependency, version] of Object.entries(member.dependencies)) {
|
|
1984
|
+
if (!(dependency in dependencies)) {
|
|
1985
|
+
dependencies[dependency] = version;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
if (member.frameworkSignals.length > 0) {
|
|
1989
|
+
evidence.push(`workspace member ${member.directory}: ${member.frameworkSignals.join(", ")}`);
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1943
1993
|
const hasExpoDependency = "expo" in dependencies;
|
|
1944
1994
|
const hasReactNativeDependency = "react-native" in dependencies;
|
|
1945
1995
|
const hasPlaywrightDependency = "@playwright/test" in dependencies || "playwright" in dependencies;
|
|
@@ -2942,21 +2992,64 @@ function toCoreFlowChangedFiles(changedFiles, scopedRoot, coreFlowRoot) {
|
|
|
2942
2992
|
previousPath: file.previousPath ? toPosixPath(path.join(relativeRoot, file.previousPath)) : undefined,
|
|
2943
2993
|
}));
|
|
2944
2994
|
}
|
|
2945
|
-
async function buildFlows(root, changedFiles, runner, projectType, testSuiteInventory, domainLanguage) {
|
|
2995
|
+
async function buildFlows(root, changedFiles, runner, projectType, testSuiteInventory, domainLanguage, addedDiffText = {}) {
|
|
2946
2996
|
const files = changedFiles.map((file) => file.path);
|
|
2997
|
+
const importImpacts = await collectImportImpacts(root, files);
|
|
2947
2998
|
const fixtureContext = await collectFixtureReadinessContext(root, files);
|
|
2948
|
-
const flowResults = await Promise.all(buildFlowCandidates(files, runner, projectType, domainLanguage).map((candidate) => buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext)));
|
|
2999
|
+
const flowResults = await Promise.all(buildFlowCandidates(files, runner, projectType, domainLanguage, importImpacts).map((candidate) => buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext, addedDiffText)));
|
|
2949
3000
|
const flows = flowResults.filter((flow) => Boolean(flow));
|
|
2950
3001
|
return dedupeFlows(flows).slice(0, 4);
|
|
2951
3002
|
}
|
|
2952
|
-
function
|
|
2953
|
-
|
|
3003
|
+
async function expandChangedFilesForMatching(root, changedFiles) {
|
|
3004
|
+
if (changedFiles.length === 0 || changedFiles.length > 60) {
|
|
3005
|
+
return changedFiles;
|
|
3006
|
+
}
|
|
3007
|
+
try {
|
|
3008
|
+
const expansion = await expandChangedFilesWithImporters(root, changedFiles.map((file) => file.path));
|
|
3009
|
+
const knownPaths = new Set(changedFiles.map((file) => file.path));
|
|
3010
|
+
const importerEntries = expansion.files
|
|
3011
|
+
.filter((file) => !knownPaths.has(file))
|
|
3012
|
+
.map((file) => ({ status: "M", path: file }));
|
|
3013
|
+
return [...changedFiles, ...importerEntries];
|
|
3014
|
+
}
|
|
3015
|
+
catch {
|
|
3016
|
+
return changedFiles;
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
function isRoutableSurfaceFile(file) {
|
|
3020
|
+
if (isApiRouteFile(file) || isTestLikeFile(file)) {
|
|
3021
|
+
return false;
|
|
3022
|
+
}
|
|
3023
|
+
return (/(?:^|\/)app\/.*(?:^|\/)?page\.[cm]?[jt]sx?$/i.test(file) ||
|
|
3024
|
+
/(?:^|\/)pages\/(?!api\/).+\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file) ||
|
|
3025
|
+
/(?:^|\/)(?:screens|views)\/.+\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file) ||
|
|
3026
|
+
/(?:^|\/)routes\/(?!api\/).+\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file));
|
|
3027
|
+
}
|
|
3028
|
+
async function collectImportImpacts(root, changedFiles) {
|
|
3029
|
+
const propagatableFiles = changedFiles.filter((file) => !isRoutableSurfaceFile(file) && !isTestLikeFile(file) && !isConfigLikeFile(file) && !isContentOrStyleFile(file));
|
|
3030
|
+
if (propagatableFiles.length === 0) {
|
|
3031
|
+
return [];
|
|
3032
|
+
}
|
|
3033
|
+
try {
|
|
3034
|
+
const index = await buildReverseImportIndex(root);
|
|
3035
|
+
return findImportingSurfaces(index, propagatableFiles, isRoutableSurfaceFile);
|
|
3036
|
+
}
|
|
3037
|
+
catch {
|
|
3038
|
+
return [];
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
function describeImportChain(impact) {
|
|
3042
|
+
return impact.chain.join(" -> ");
|
|
3043
|
+
}
|
|
3044
|
+
function buildFlowCandidates(files, runner, projectType, domainLanguage, importImpacts = []) {
|
|
3045
|
+
const lowSignalCandidate = importImpacts.length === 0 ? buildLowSignalChangeCandidate(files) : undefined;
|
|
2954
3046
|
if (lowSignalCandidate) {
|
|
2955
3047
|
return [lowSignalCandidate];
|
|
2956
3048
|
}
|
|
2957
3049
|
const behaviorFiles = files.filter((file) => !isTestLikeFile(file));
|
|
2958
3050
|
const candidateFiles = behaviorFiles.length > 0 ? behaviorFiles : files;
|
|
2959
|
-
const
|
|
3051
|
+
const impactSurfaceFiles = importImpacts.map((impact) => impact.surface).filter((surface) => !candidateFiles.includes(surface));
|
|
3052
|
+
const uiFiles = uniqueStrings([...candidateFiles.filter(isUserFacingFile), ...impactSurfaceFiles]);
|
|
2960
3053
|
const apiFiles = candidateFiles.filter(isApiLikeFile);
|
|
2961
3054
|
const apiServiceSourceFiles = projectType === "api-service"
|
|
2962
3055
|
? candidateFiles.filter((file) => !apiFiles.includes(file) && !isConfigLikeFile(file) && isServiceSourceFile(file))
|
|
@@ -2975,11 +3068,14 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage) {
|
|
|
2975
3068
|
const candidates = [];
|
|
2976
3069
|
if (uiFiles.length > 0) {
|
|
2977
3070
|
const subject = summarizeFlowSubject(uiFiles, "Changed", domainLanguage);
|
|
3071
|
+
const impactReason = importImpacts.length > 0
|
|
3072
|
+
? ` Changed shared files reach these surfaces through imports: ${importImpacts.slice(0, 3).map(describeImportChain).join("; ")}.`
|
|
3073
|
+
: "";
|
|
2978
3074
|
candidates.push({
|
|
2979
3075
|
kind: "ui",
|
|
2980
3076
|
title: `${subject} UI smoke flow`,
|
|
2981
|
-
reason:
|
|
2982
|
-
files: uiFiles,
|
|
3077
|
+
reason: `User-facing route, screen, navigation, or component files changed, so the draft should open the touched surface and cover the primary visible action.${impactReason}`,
|
|
3078
|
+
files: uniqueStrings([...uiFiles, ...importImpacts.map((impact) => impact.changedFile)]),
|
|
2983
3079
|
steps: [
|
|
2984
3080
|
"Launch the app.",
|
|
2985
3081
|
"Navigate to the changed screen or component surface.",
|
|
@@ -3212,14 +3308,14 @@ function buildLowSignalChangeCandidate(files) {
|
|
|
3212
3308
|
}
|
|
3213
3309
|
return undefined;
|
|
3214
3310
|
}
|
|
3215
|
-
async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext) {
|
|
3311
|
+
async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext, addedDiffText = {}) {
|
|
3216
3312
|
const files = uniqueStrings(candidate.files).slice(0, 20);
|
|
3217
3313
|
if (files.length === 0) {
|
|
3218
3314
|
return undefined;
|
|
3219
3315
|
}
|
|
3220
3316
|
const coverage = buildCoverageTargets(candidate.kind, files, runner);
|
|
3221
3317
|
const setupHints = await inferFlowSetupHints(root, files, candidate.kind);
|
|
3222
|
-
const selectors = await inferFlowSelectors(root, files, runner);
|
|
3318
|
+
const selectors = await inferFlowSelectors(root, files, runner, addedDiffText);
|
|
3223
3319
|
const flow = {
|
|
3224
3320
|
title: candidate.title,
|
|
3225
3321
|
reason: candidate.reason,
|
|
@@ -3238,9 +3334,12 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
|
|
|
3238
3334
|
languageBrief: buildFlowLanguageBrief(flow),
|
|
3239
3335
|
};
|
|
3240
3336
|
}
|
|
3337
|
+
function preferDiffAdded(selectors, predicate) {
|
|
3338
|
+
return selectors.find((selector) => selector.addedInDiff && predicate(selector)) ?? selectors.find(predicate);
|
|
3339
|
+
}
|
|
3241
3340
|
function refineStepsForInferredSelectors(steps, selectors) {
|
|
3242
|
-
const inputSelector = selectors
|
|
3243
|
-
const actionSelector = selectors
|
|
3341
|
+
const inputSelector = preferDiffAdded(selectors, isInputSelector);
|
|
3342
|
+
const actionSelector = preferDiffAdded(selectors, (selector) => selectorCanDriveInteraction(selector) && !isInputSelector(selector));
|
|
3244
3343
|
if (!inputSelector || !actionSelector || steps.some((step) => /^\s*(?:fill|input|enter|type|provide|write)\b/i.test(step))) {
|
|
3245
3344
|
return steps;
|
|
3246
3345
|
}
|
|
@@ -3257,8 +3356,8 @@ function refineStepsForInferredSelectors(steps, selectors) {
|
|
|
3257
3356
|
return uniqueStrings(refined);
|
|
3258
3357
|
}
|
|
3259
3358
|
function refineManifestStepsForInferredSelectors(steps, selectors) {
|
|
3260
|
-
const inputSelector = selectors
|
|
3261
|
-
const actionSelector = selectors
|
|
3359
|
+
const inputSelector = preferDiffAdded(selectors, isInputSelector);
|
|
3360
|
+
const actionSelector = preferDiffAdded(selectors, (selector) => selectorCanDriveInteraction(selector) && !isInputSelector(selector));
|
|
3262
3361
|
if (!inputSelector || !actionSelector || steps.some(isInputStep)) {
|
|
3263
3362
|
return steps;
|
|
3264
3363
|
}
|
|
@@ -3280,7 +3379,12 @@ function exerciseStepSubject(step) {
|
|
|
3280
3379
|
return undefined;
|
|
3281
3380
|
}
|
|
3282
3381
|
function selectorStepLabel(selector) {
|
|
3283
|
-
|
|
3382
|
+
const label = titleCase(selector.value.replace(/[-_]+/g, " "));
|
|
3383
|
+
if (label) {
|
|
3384
|
+
return label;
|
|
3385
|
+
}
|
|
3386
|
+
const raw = selector.value.trim();
|
|
3387
|
+
return raw.length > 0 ? `"${raw}"` : `the ${selector.kind} control`;
|
|
3284
3388
|
}
|
|
3285
3389
|
function actionVerbForSelector(selector) {
|
|
3286
3390
|
if (/\b(?:submit|send|apply|complete|confirm|save|continue|next|upload)\b/i.test(selector.value.replace(/[-_]+/g, " "))) {
|
|
@@ -3588,7 +3692,8 @@ function routeSegmentsFromFileParts(parts) {
|
|
|
3588
3692
|
function isBackendImplementationFile(file) {
|
|
3589
3693
|
return /(?:^|\/)(?:server|servers|backend|api|apis|routes|controllers?|handlers?|resolvers?|endpoints?)\//i.test(file) ||
|
|
3590
3694
|
/(?:openapi|swagger|schema|controller|handler|resolver|route)\.(?:json|ya?ml|[cm]?[jt]sx?|py|go|rs|kt|java|rb|php)$/i.test(file) ||
|
|
3591
|
-
/(?:^|\/)(?:urls|views|serializers|routers|controllers?|handlers?|admin|models|services|tasks|permissions|filters)
|
|
3695
|
+
/(?:^|\/)(?:urls|views|viewsets|serializers|routers|controllers?|handlers?|admin|models|services|tasks|permissions|filters|consumers|signals)\w*\.py$/i.test(file) ||
|
|
3696
|
+
/(?:^|\/)(?:views|viewsets|serializers|services|routers|handlers|tasks|consumers)\/[^/]+\.py$/i.test(file);
|
|
3592
3697
|
}
|
|
3593
3698
|
function isMockOrFixtureFile(file) {
|
|
3594
3699
|
if (isFixtureEvidenceIgnoredPath(file)) {
|
|
@@ -3665,7 +3770,7 @@ const fixtureEvidenceIgnoredTokens = new Set([
|
|
|
3665
3770
|
"test",
|
|
3666
3771
|
"tests",
|
|
3667
3772
|
]);
|
|
3668
|
-
async function inferFlowSelectors(root, files, runner) {
|
|
3773
|
+
async function inferFlowSelectors(root, files, runner, addedDiffText = {}) {
|
|
3669
3774
|
const selectors = [];
|
|
3670
3775
|
for (const file of files.slice(0, 8)) {
|
|
3671
3776
|
if (!isUiImplementationFile(file)) {
|
|
@@ -3675,7 +3780,10 @@ async function inferFlowSelectors(root, files, runner) {
|
|
|
3675
3780
|
if (!text) {
|
|
3676
3781
|
continue;
|
|
3677
3782
|
}
|
|
3678
|
-
|
|
3783
|
+
const addedText = addedDiffText[file];
|
|
3784
|
+
for (const selector of extractSelectorsFromText(file, text, runner)) {
|
|
3785
|
+
selectors.push(addedText && addedText.includes(selector.value) ? { ...selector, addedInDiff: true } : selector);
|
|
3786
|
+
}
|
|
3679
3787
|
}
|
|
3680
3788
|
return uniqueSelectors(selectors).slice(0, 12);
|
|
3681
3789
|
}
|
|
@@ -4085,7 +4193,8 @@ function isUiImplementationFile(file) {
|
|
|
4085
4193
|
}
|
|
4086
4194
|
function isServiceSourceFile(file) {
|
|
4087
4195
|
return /(?:^|\/)src\/.+\.(?:[cm]?[jt]s|py|go|rs|java|kt|cs)$/i.test(file) ||
|
|
4088
|
-
/(?:^|\/)(?:urls|views|serializers|routers|controllers?|handlers?|admin|models|services|tasks|permissions|filters)
|
|
4196
|
+
/(?:^|\/)(?:urls|views|viewsets|serializers|routers|controllers?|handlers?|admin|models|services|tasks|permissions|filters|consumers|signals)\w*\.py$/i.test(file) ||
|
|
4197
|
+
/(?:^|\/)(?:views|viewsets|serializers|services|api|apis|routers|handlers|tasks|consumers)\/[^/]+\.py$/i.test(file);
|
|
4089
4198
|
}
|
|
4090
4199
|
function summarizeFlowSubject(files, fallback, domainLanguage) {
|
|
4091
4200
|
const languageSubject = summarizeFlowSubjectFromDomainLanguage(files, domainLanguage);
|
|
@@ -4338,7 +4447,7 @@ function titleCase(value) {
|
|
|
4338
4447
|
return value
|
|
4339
4448
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
4340
4449
|
.replace(/[-_]+/g, " ")
|
|
4341
|
-
.replace(/[
|
|
4450
|
+
.replace(/[^\p{L}\p{N} ]+/gu, " ")
|
|
4342
4451
|
.trim()
|
|
4343
4452
|
.split(/\s+/)
|
|
4344
4453
|
.filter(Boolean)
|
|
@@ -5109,12 +5218,12 @@ function draftExtension(runner) {
|
|
|
5109
5218
|
}
|
|
5110
5219
|
return ".md";
|
|
5111
5220
|
}
|
|
5112
|
-
function draftContentForFlow(plan, flow, runner) {
|
|
5221
|
+
function draftContentForFlow(plan, flow, runner, addedDiffText = {}) {
|
|
5113
5222
|
if (runner === "maestro") {
|
|
5114
5223
|
return buildMaestroDraft(plan, flow);
|
|
5115
5224
|
}
|
|
5116
5225
|
if (runner === "playwright") {
|
|
5117
|
-
return buildPlaywrightDraft(plan, flow);
|
|
5226
|
+
return buildPlaywrightDraft(plan, flow, addedDiffText);
|
|
5118
5227
|
}
|
|
5119
5228
|
return buildManualDraft(plan, flow);
|
|
5120
5229
|
}
|
|
@@ -5207,7 +5316,7 @@ function maestroCommandForStep(step, selectors) {
|
|
|
5207
5316
|
value: maestroSelectorValue(selector),
|
|
5208
5317
|
};
|
|
5209
5318
|
}
|
|
5210
|
-
function buildPlaywrightDraft(plan, flow) {
|
|
5319
|
+
function buildPlaywrightDraft(plan, flow, addedDiffText = {}) {
|
|
5211
5320
|
const testName = flow.title.replaceAll('"', "'");
|
|
5212
5321
|
const selectorQueue = [...flow.selectors];
|
|
5213
5322
|
const scenario = domainScenarioForFlow(flow);
|
|
@@ -5264,6 +5373,7 @@ function buildPlaywrightDraft(plan, flow) {
|
|
|
5264
5373
|
: playwrightFallbackActionForStep(step));
|
|
5265
5374
|
appendPlaywrightTestStep(lines, step, body);
|
|
5266
5375
|
}
|
|
5376
|
+
appendObservedResponseAssertion(lines, flow, addedDiffText);
|
|
5267
5377
|
appendDomainScenarioComments(lines, flow, " //");
|
|
5268
5378
|
appendPlaywrightCoverageComments(lines, flow);
|
|
5269
5379
|
lines.push("});");
|
|
@@ -5861,12 +5971,7 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
|
|
|
5861
5971
|
if (flow.fixtureReadiness.status === "not-needed" || flow.fixtureReadiness.apiEndpoints.length === 0) {
|
|
5862
5972
|
return;
|
|
5863
5973
|
}
|
|
5864
|
-
const
|
|
5865
|
-
const observedEndpoints = changedEndpointHints.length > 0
|
|
5866
|
-
? changedEndpointHints
|
|
5867
|
-
: flow.fixtureReadiness.backendSignals.length > 0
|
|
5868
|
-
? flow.fixtureReadiness.apiEndpoints
|
|
5869
|
-
: [];
|
|
5974
|
+
const observedEndpoints = observedEndpointsForFlow(flow);
|
|
5870
5975
|
if (observedEndpoints.length > 0) {
|
|
5871
5976
|
appendPlaywrightApiObservationScaffold(lines, observedEndpoints);
|
|
5872
5977
|
}
|
|
@@ -5899,6 +6004,66 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
|
|
|
5899
6004
|
lines.push(" });");
|
|
5900
6005
|
lines.push(" }");
|
|
5901
6006
|
}
|
|
6007
|
+
function observedEndpointsForFlow(flow) {
|
|
6008
|
+
if (flow.fixtureReadiness.status === "not-needed" || flow.fixtureReadiness.apiEndpoints.length === 0) {
|
|
6009
|
+
return [];
|
|
6010
|
+
}
|
|
6011
|
+
const changedEndpointHints = uniqueStrings(flow.fixtureReadiness.backendSignals.flatMap(apiEndpointFromBackendFile));
|
|
6012
|
+
if (changedEndpointHints.length > 0) {
|
|
6013
|
+
return changedEndpointHints;
|
|
6014
|
+
}
|
|
6015
|
+
return flow.fixtureReadiness.backendSignals.length > 0 ? flow.fixtureReadiness.apiEndpoints : [];
|
|
6016
|
+
}
|
|
6017
|
+
function collectChangedHandlerEvidence(flow, addedDiffText) {
|
|
6018
|
+
const statuses = [];
|
|
6019
|
+
const responseKeys = [];
|
|
6020
|
+
for (const file of flow.fixtureReadiness.backendSignals) {
|
|
6021
|
+
const addedText = addedDiffText[file];
|
|
6022
|
+
if (!addedText) {
|
|
6023
|
+
continue;
|
|
6024
|
+
}
|
|
6025
|
+
for (const match of addedText.matchAll(/(?:status[:(]\s*|\.status\(\s*)(\d{3})\b/g)) {
|
|
6026
|
+
const status = Number(match[1]);
|
|
6027
|
+
if (status >= 100 && status < 600) {
|
|
6028
|
+
statuses.push(status);
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
for (const match of addedText.matchAll(/(?:NextResponse|Response|res)\.json\(\s*\{([^}]{1,200})\}/g)) {
|
|
6032
|
+
for (const keyMatch of match[1].matchAll(/(?:^|[,{])\s*([A-Za-z_$][\w$]*)\s*[:,}]/g)) {
|
|
6033
|
+
responseKeys.push(keyMatch[1]);
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
}
|
|
6037
|
+
const uniqueStatuses = [...new Set(statuses)];
|
|
6038
|
+
return {
|
|
6039
|
+
statuses: uniqueStatuses,
|
|
6040
|
+
successOnly: uniqueStatuses.length > 0 && uniqueStatuses.every((status) => status < 400),
|
|
6041
|
+
responseKeys: [...new Set(responseKeys)].slice(0, 6),
|
|
6042
|
+
};
|
|
6043
|
+
}
|
|
6044
|
+
function appendObservedResponseAssertion(lines, flow, addedDiffText) {
|
|
6045
|
+
if (observedEndpointsForFlow(flow).length === 0) {
|
|
6046
|
+
return;
|
|
6047
|
+
}
|
|
6048
|
+
const evidence = collectChangedHandlerEvidence(flow, addedDiffText);
|
|
6049
|
+
const statusCeiling = evidence.successOnly ? 400 : 500;
|
|
6050
|
+
lines.push("");
|
|
6051
|
+
if (evidence.successOnly) {
|
|
6052
|
+
lines.push(` // The added handler code only shows success statuses (${evidence.statuses.join(", ")}), so any 4xx/5xx here is unexpected.`);
|
|
6053
|
+
}
|
|
6054
|
+
else {
|
|
6055
|
+
lines.push(" // Changed-endpoint check: the journey must not surface server errors from the code under test.");
|
|
6056
|
+
}
|
|
6057
|
+
if (evidence.responseKeys.length > 0) {
|
|
6058
|
+
lines.push(` // Response shape hint from the changed handler: { ${evidence.responseKeys.join(", ")} } — assert on these fields when promoting this draft.`);
|
|
6059
|
+
}
|
|
6060
|
+
lines.push(" for (const response of observedChangedApiResponses) {");
|
|
6061
|
+
lines.push(` expect(response.status, \`unexpected status from \${response.url}\`).toBeLessThan(${statusCeiling});`);
|
|
6062
|
+
lines.push(" }");
|
|
6063
|
+
lines.push(" if (observedChangedApiResponses.length === 0) {");
|
|
6064
|
+
lines.push(' console.warn("Changed endpoints were not exercised by this draft; extend the steps above to cover them.");');
|
|
6065
|
+
lines.push(" }");
|
|
6066
|
+
}
|
|
5902
6067
|
function appendPlaywrightApiObservationScaffold(lines, endpoints) {
|
|
5903
6068
|
lines.push("");
|
|
5904
6069
|
lines.push(" const changedApiEndpointPatterns = [");
|
|
@@ -6248,42 +6413,56 @@ function formatMaestroCommand(command) {
|
|
|
6248
6413
|
}
|
|
6249
6414
|
return [`- ${command.kind}: ${command.value}`];
|
|
6250
6415
|
}
|
|
6416
|
+
function takePreferredSelector(selectors, predicate, diffGate = predicate) {
|
|
6417
|
+
let firstIndex = -1;
|
|
6418
|
+
for (let index = 0; index < selectors.length; index += 1) {
|
|
6419
|
+
const selector = selectors[index];
|
|
6420
|
+
if (!predicate(selector)) {
|
|
6421
|
+
continue;
|
|
6422
|
+
}
|
|
6423
|
+
if (selector.addedInDiff && diffGate(selector)) {
|
|
6424
|
+
return selectors.splice(index, 1)[0];
|
|
6425
|
+
}
|
|
6426
|
+
if (firstIndex === -1) {
|
|
6427
|
+
firstIndex = index;
|
|
6428
|
+
}
|
|
6429
|
+
}
|
|
6430
|
+
return firstIndex >= 0 ? selectors.splice(firstIndex, 1)[0] : undefined;
|
|
6431
|
+
}
|
|
6251
6432
|
function takeSelectorForStep(selectors, step) {
|
|
6252
6433
|
if (/^launch\b/i.test(step)) {
|
|
6253
6434
|
return undefined;
|
|
6254
6435
|
}
|
|
6255
6436
|
if (isInputStep(step)) {
|
|
6256
|
-
const
|
|
6257
|
-
if (
|
|
6258
|
-
|
|
6259
|
-
return selector;
|
|
6437
|
+
const matched = takePreferredSelector(selectors, (selector) => isInputSelector(selector) && selectorMatchesStep(selector, step));
|
|
6438
|
+
if (matched) {
|
|
6439
|
+
return matched;
|
|
6260
6440
|
}
|
|
6261
|
-
const
|
|
6262
|
-
if (
|
|
6263
|
-
|
|
6264
|
-
return selector;
|
|
6441
|
+
const fallbackInput = takePreferredSelector(selectors, isInputSelector);
|
|
6442
|
+
if (fallbackInput) {
|
|
6443
|
+
return fallbackInput;
|
|
6265
6444
|
}
|
|
6266
6445
|
}
|
|
6267
6446
|
if (!isInteractionStep(step) && !isVerificationStep(step) && !canUsePrimarySelector(step)) {
|
|
6268
6447
|
return undefined;
|
|
6269
6448
|
}
|
|
6270
|
-
const
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6449
|
+
const diffGateForStep = isAssertionStep(step) || isVerificationStep(step)
|
|
6450
|
+
? selectorCanSupportAssertion
|
|
6451
|
+
: selectorCanDriveInteraction;
|
|
6452
|
+
const matched = takePreferredSelector(selectors, (selector) => !isInputSelector(selector) && selectorMatchesStep(selector, step), diffGateForStep);
|
|
6453
|
+
if (matched) {
|
|
6454
|
+
return matched;
|
|
6274
6455
|
}
|
|
6275
6456
|
if (isAssertionStep(step)) {
|
|
6276
|
-
const
|
|
6277
|
-
if (
|
|
6278
|
-
|
|
6279
|
-
return selector;
|
|
6457
|
+
const assertion = takePreferredSelector(selectors, selectorCanSupportAssertion);
|
|
6458
|
+
if (assertion) {
|
|
6459
|
+
return assertion;
|
|
6280
6460
|
}
|
|
6281
6461
|
}
|
|
6282
6462
|
if (canUsePrimarySelector(step)) {
|
|
6283
|
-
const
|
|
6284
|
-
if (
|
|
6285
|
-
|
|
6286
|
-
return selector;
|
|
6463
|
+
const fallback = takePreferredSelector(selectors, selectorCanDriveInteraction);
|
|
6464
|
+
if (fallback) {
|
|
6465
|
+
return fallback;
|
|
6287
6466
|
}
|
|
6288
6467
|
}
|
|
6289
6468
|
return undefined;
|
|
@@ -6586,7 +6765,7 @@ function withoutSelectorValueDuplicates(selectors, existing) {
|
|
|
6586
6765
|
function extractAttributeSelectors(file, text, attributes, kind) {
|
|
6587
6766
|
const selectors = [];
|
|
6588
6767
|
for (const attribute of attributes) {
|
|
6589
|
-
const matcher = new RegExp(
|
|
6768
|
+
const matcher = new RegExp(`(?<![:@.\\w-])${escapeRegExp(attribute)}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{\\s*["'\`]([^"'\`{}]+)["'\`]\\s*\\})`, "g");
|
|
6590
6769
|
for (const match of text.matchAll(matcher)) {
|
|
6591
6770
|
const value = normalizeSelectorValue(match[1] ?? match[2] ?? match[3]);
|
|
6592
6771
|
if (value) {
|
|
@@ -6644,7 +6823,15 @@ function normalizeSelectorValue(value) {
|
|
|
6644
6823
|
return normalized || undefined;
|
|
6645
6824
|
}
|
|
6646
6825
|
function isUsefulSelector(value) {
|
|
6647
|
-
|
|
6826
|
+
if (value.length < 2 || value.length > 80 || /[{}()[\]=>]/.test(value)) {
|
|
6827
|
+
return false;
|
|
6828
|
+
}
|
|
6829
|
+
// Dotted tokens without spaces are almost always i18n keys or property paths,
|
|
6830
|
+
// never rendered UI text, so a locator built from them can never match.
|
|
6831
|
+
if (/^[\w$-]+(?:\.[\w$-]+)+$/.test(value)) {
|
|
6832
|
+
return false;
|
|
6833
|
+
}
|
|
6834
|
+
return true;
|
|
6648
6835
|
}
|
|
6649
6836
|
async function readPackageJson(root) {
|
|
6650
6837
|
try {
|
|
@@ -6654,6 +6841,77 @@ async function readPackageJson(root) {
|
|
|
6654
6841
|
return undefined;
|
|
6655
6842
|
}
|
|
6656
6843
|
}
|
|
6844
|
+
const maxWorkspaceMembers = 30;
|
|
6845
|
+
async function collectWorkspaceMemberDependencies(root, packageJson) {
|
|
6846
|
+
const patterns = await readWorkspaceMemberPatterns(root, packageJson);
|
|
6847
|
+
if (patterns.length === 0) {
|
|
6848
|
+
return [];
|
|
6849
|
+
}
|
|
6850
|
+
const memberDirectories = await expandWorkspaceMemberPatterns(root, patterns);
|
|
6851
|
+
const members = [];
|
|
6852
|
+
for (const directory of memberDirectories.slice(0, maxWorkspaceMembers)) {
|
|
6853
|
+
const memberPackageJson = await readPackageJson(path.join(root, directory));
|
|
6854
|
+
if (!memberPackageJson) {
|
|
6855
|
+
continue;
|
|
6856
|
+
}
|
|
6857
|
+
const dependencies = {
|
|
6858
|
+
...(memberPackageJson.dependencies ?? {}),
|
|
6859
|
+
...(memberPackageJson.devDependencies ?? {}),
|
|
6860
|
+
};
|
|
6861
|
+
members.push({
|
|
6862
|
+
directory,
|
|
6863
|
+
dependencies,
|
|
6864
|
+
frameworkSignals: frameworkSignalDependencies.filter((dependency) => dependency in dependencies),
|
|
6865
|
+
});
|
|
6866
|
+
}
|
|
6867
|
+
return members;
|
|
6868
|
+
}
|
|
6869
|
+
async function readWorkspaceMemberPatterns(root, packageJson) {
|
|
6870
|
+
const patterns = [];
|
|
6871
|
+
const workspaces = packageJson?.workspaces;
|
|
6872
|
+
if (Array.isArray(workspaces)) {
|
|
6873
|
+
patterns.push(...workspaces);
|
|
6874
|
+
}
|
|
6875
|
+
else if (workspaces?.packages) {
|
|
6876
|
+
patterns.push(...workspaces.packages);
|
|
6877
|
+
}
|
|
6878
|
+
const workspaceYaml = await readTextIfExists(path.join(root, "pnpm-workspace.yaml"));
|
|
6879
|
+
if (workspaceYaml) {
|
|
6880
|
+
try {
|
|
6881
|
+
const parsed = YAML.parse(workspaceYaml);
|
|
6882
|
+
patterns.push(...(parsed?.packages ?? []));
|
|
6883
|
+
}
|
|
6884
|
+
catch {
|
|
6885
|
+
// ignore unparseable workspace config
|
|
6886
|
+
}
|
|
6887
|
+
}
|
|
6888
|
+
return uniqueStrings(patterns.filter((pattern) => typeof pattern === "string" && !pattern.startsWith("!")));
|
|
6889
|
+
}
|
|
6890
|
+
async function expandWorkspaceMemberPatterns(root, patterns) {
|
|
6891
|
+
const directories = [];
|
|
6892
|
+
for (const pattern of patterns) {
|
|
6893
|
+
const normalized = pattern.replace(/\/\*{1,2}$/, "");
|
|
6894
|
+
if (normalized.includes("*")) {
|
|
6895
|
+
continue;
|
|
6896
|
+
}
|
|
6897
|
+
if (normalized === pattern) {
|
|
6898
|
+
directories.push(normalized);
|
|
6899
|
+
continue;
|
|
6900
|
+
}
|
|
6901
|
+
try {
|
|
6902
|
+
const entries = await fs.readdir(path.join(root, normalized), { withFileTypes: true });
|
|
6903
|
+
for (const entry of entries) {
|
|
6904
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
6905
|
+
directories.push(`${normalized}/${entry.name}`);
|
|
6906
|
+
}
|
|
6907
|
+
}
|
|
6908
|
+
}
|
|
6909
|
+
catch {
|
|
6910
|
+
continue;
|
|
6911
|
+
}
|
|
6912
|
+
}
|
|
6913
|
+
return uniqueStrings(directories);
|
|
6914
|
+
}
|
|
6657
6915
|
async function readTextIfExists(filePath) {
|
|
6658
6916
|
try {
|
|
6659
6917
|
return await fs.readFile(filePath, "utf8");
|