@ivorycanvas/qamap 0.3.1 → 0.3.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/CHANGELOG.md +22 -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 +35 -6
- package/dist/domain-language.js.map +1 -1
- package/dist/e2e.d.ts +1 -0
- package/dist/e2e.js +234 -46
- 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/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 +21 -5
- package/docs/roadmap.md +0 -1
- package/docs/verify.md +1 -1
- package/package.json +3 -2
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),
|
|
@@ -1214,7 +1223,7 @@ function inferFlowActor(flow) {
|
|
|
1214
1223
|
if (/\b(auth|login|logout|session|account|profile|permission)\b/.test(haystack)) {
|
|
1215
1224
|
return "Signed-in user or guest";
|
|
1216
1225
|
}
|
|
1217
|
-
if (/\b(checkout|purchase|payment|order|cart|offer|subscription|membership|billing)\b/.test(haystack)) {
|
|
1226
|
+
if (/\b(checkout|purchase|payment|order|cart|offer|listing|subscription|membership|billing)\b/.test(haystack)) {
|
|
1218
1227
|
return "Customer";
|
|
1219
1228
|
}
|
|
1220
1229
|
if (hasUserFacingEntrypointOrFile(flow)) {
|
|
@@ -1929,6 +1938,27 @@ export function formatMarkdownE2eSetup(result) {
|
|
|
1929
1938
|
}
|
|
1930
1939
|
return lines.join("\n");
|
|
1931
1940
|
}
|
|
1941
|
+
const frameworkSignalDependencies = [
|
|
1942
|
+
"expo",
|
|
1943
|
+
"react-native",
|
|
1944
|
+
"@playwright/test",
|
|
1945
|
+
"playwright",
|
|
1946
|
+
"@angular/core",
|
|
1947
|
+
"@remix-run/react",
|
|
1948
|
+
"astro",
|
|
1949
|
+
"next",
|
|
1950
|
+
"nuxt",
|
|
1951
|
+
"react-dom",
|
|
1952
|
+
"react-router-dom",
|
|
1953
|
+
"svelte",
|
|
1954
|
+
"vue",
|
|
1955
|
+
"vite",
|
|
1956
|
+
"@nestjs/core",
|
|
1957
|
+
"express",
|
|
1958
|
+
"fastify",
|
|
1959
|
+
"koa",
|
|
1960
|
+
"hono",
|
|
1961
|
+
];
|
|
1932
1962
|
async function detectProjectProfile(root, workspaceRoot) {
|
|
1933
1963
|
const profileRoots = profileSearchRoots(root, workspaceRoot);
|
|
1934
1964
|
const packageJson = await readPackageJson(root);
|
|
@@ -1940,6 +1970,20 @@ async function detectProjectProfile(root, workspaceRoot) {
|
|
|
1940
1970
|
...(packageJson?.devDependencies ?? {}),
|
|
1941
1971
|
};
|
|
1942
1972
|
const evidence = [];
|
|
1973
|
+
const rootHasFrameworkSignal = frameworkSignalDependencies.some((dependency) => dependency in dependencies);
|
|
1974
|
+
if (!rootHasFrameworkSignal) {
|
|
1975
|
+
const memberDependencies = await collectWorkspaceMemberDependencies(root, packageJson);
|
|
1976
|
+
for (const member of memberDependencies) {
|
|
1977
|
+
for (const [dependency, version] of Object.entries(member.dependencies)) {
|
|
1978
|
+
if (!(dependency in dependencies)) {
|
|
1979
|
+
dependencies[dependency] = version;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
if (member.frameworkSignals.length > 0) {
|
|
1983
|
+
evidence.push(`workspace member ${member.directory}: ${member.frameworkSignals.join(", ")}`);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1943
1987
|
const hasExpoDependency = "expo" in dependencies;
|
|
1944
1988
|
const hasReactNativeDependency = "react-native" in dependencies;
|
|
1945
1989
|
const hasPlaywrightDependency = "@playwright/test" in dependencies || "playwright" in dependencies;
|
|
@@ -2942,21 +2986,64 @@ function toCoreFlowChangedFiles(changedFiles, scopedRoot, coreFlowRoot) {
|
|
|
2942
2986
|
previousPath: file.previousPath ? toPosixPath(path.join(relativeRoot, file.previousPath)) : undefined,
|
|
2943
2987
|
}));
|
|
2944
2988
|
}
|
|
2945
|
-
async function buildFlows(root, changedFiles, runner, projectType, testSuiteInventory, domainLanguage) {
|
|
2989
|
+
async function buildFlows(root, changedFiles, runner, projectType, testSuiteInventory, domainLanguage, addedDiffText = {}) {
|
|
2946
2990
|
const files = changedFiles.map((file) => file.path);
|
|
2991
|
+
const importImpacts = await collectImportImpacts(root, files);
|
|
2947
2992
|
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)));
|
|
2993
|
+
const flowResults = await Promise.all(buildFlowCandidates(files, runner, projectType, domainLanguage, importImpacts).map((candidate) => buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext, addedDiffText)));
|
|
2949
2994
|
const flows = flowResults.filter((flow) => Boolean(flow));
|
|
2950
2995
|
return dedupeFlows(flows).slice(0, 4);
|
|
2951
2996
|
}
|
|
2952
|
-
function
|
|
2953
|
-
|
|
2997
|
+
async function expandChangedFilesForMatching(root, changedFiles) {
|
|
2998
|
+
if (changedFiles.length === 0 || changedFiles.length > 60) {
|
|
2999
|
+
return changedFiles;
|
|
3000
|
+
}
|
|
3001
|
+
try {
|
|
3002
|
+
const expansion = await expandChangedFilesWithImporters(root, changedFiles.map((file) => file.path));
|
|
3003
|
+
const knownPaths = new Set(changedFiles.map((file) => file.path));
|
|
3004
|
+
const importerEntries = expansion.files
|
|
3005
|
+
.filter((file) => !knownPaths.has(file))
|
|
3006
|
+
.map((file) => ({ status: "M", path: file }));
|
|
3007
|
+
return [...changedFiles, ...importerEntries];
|
|
3008
|
+
}
|
|
3009
|
+
catch {
|
|
3010
|
+
return changedFiles;
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
function isRoutableSurfaceFile(file) {
|
|
3014
|
+
if (isApiRouteFile(file) || isTestLikeFile(file)) {
|
|
3015
|
+
return false;
|
|
3016
|
+
}
|
|
3017
|
+
return (/(?:^|\/)app\/.*(?:^|\/)?page\.[cm]?[jt]sx?$/i.test(file) ||
|
|
3018
|
+
/(?:^|\/)pages\/(?!api\/).+\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file) ||
|
|
3019
|
+
/(?:^|\/)(?:screens|views)\/.+\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file) ||
|
|
3020
|
+
/(?:^|\/)routes\/(?!api\/).+\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file));
|
|
3021
|
+
}
|
|
3022
|
+
async function collectImportImpacts(root, changedFiles) {
|
|
3023
|
+
const propagatableFiles = changedFiles.filter((file) => !isRoutableSurfaceFile(file) && !isTestLikeFile(file) && !isConfigLikeFile(file) && !isContentOrStyleFile(file));
|
|
3024
|
+
if (propagatableFiles.length === 0) {
|
|
3025
|
+
return [];
|
|
3026
|
+
}
|
|
3027
|
+
try {
|
|
3028
|
+
const index = await buildReverseImportIndex(root);
|
|
3029
|
+
return findImportingSurfaces(index, propagatableFiles, isRoutableSurfaceFile);
|
|
3030
|
+
}
|
|
3031
|
+
catch {
|
|
3032
|
+
return [];
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
function describeImportChain(impact) {
|
|
3036
|
+
return impact.chain.join(" -> ");
|
|
3037
|
+
}
|
|
3038
|
+
function buildFlowCandidates(files, runner, projectType, domainLanguage, importImpacts = []) {
|
|
3039
|
+
const lowSignalCandidate = importImpacts.length === 0 ? buildLowSignalChangeCandidate(files) : undefined;
|
|
2954
3040
|
if (lowSignalCandidate) {
|
|
2955
3041
|
return [lowSignalCandidate];
|
|
2956
3042
|
}
|
|
2957
3043
|
const behaviorFiles = files.filter((file) => !isTestLikeFile(file));
|
|
2958
3044
|
const candidateFiles = behaviorFiles.length > 0 ? behaviorFiles : files;
|
|
2959
|
-
const
|
|
3045
|
+
const impactSurfaceFiles = importImpacts.map((impact) => impact.surface).filter((surface) => !candidateFiles.includes(surface));
|
|
3046
|
+
const uiFiles = uniqueStrings([...candidateFiles.filter(isUserFacingFile), ...impactSurfaceFiles]);
|
|
2960
3047
|
const apiFiles = candidateFiles.filter(isApiLikeFile);
|
|
2961
3048
|
const apiServiceSourceFiles = projectType === "api-service"
|
|
2962
3049
|
? candidateFiles.filter((file) => !apiFiles.includes(file) && !isConfigLikeFile(file) && isServiceSourceFile(file))
|
|
@@ -2975,11 +3062,14 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage) {
|
|
|
2975
3062
|
const candidates = [];
|
|
2976
3063
|
if (uiFiles.length > 0) {
|
|
2977
3064
|
const subject = summarizeFlowSubject(uiFiles, "Changed", domainLanguage);
|
|
3065
|
+
const impactReason = importImpacts.length > 0
|
|
3066
|
+
? ` Changed shared files reach these surfaces through imports: ${importImpacts.slice(0, 3).map(describeImportChain).join("; ")}.`
|
|
3067
|
+
: "";
|
|
2978
3068
|
candidates.push({
|
|
2979
3069
|
kind: "ui",
|
|
2980
3070
|
title: `${subject} UI smoke flow`,
|
|
2981
|
-
reason:
|
|
2982
|
-
files: uiFiles,
|
|
3071
|
+
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}`,
|
|
3072
|
+
files: uniqueStrings([...uiFiles, ...importImpacts.map((impact) => impact.changedFile)]),
|
|
2983
3073
|
steps: [
|
|
2984
3074
|
"Launch the app.",
|
|
2985
3075
|
"Navigate to the changed screen or component surface.",
|
|
@@ -3212,14 +3302,14 @@ function buildLowSignalChangeCandidate(files) {
|
|
|
3212
3302
|
}
|
|
3213
3303
|
return undefined;
|
|
3214
3304
|
}
|
|
3215
|
-
async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext) {
|
|
3305
|
+
async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext, addedDiffText = {}) {
|
|
3216
3306
|
const files = uniqueStrings(candidate.files).slice(0, 20);
|
|
3217
3307
|
if (files.length === 0) {
|
|
3218
3308
|
return undefined;
|
|
3219
3309
|
}
|
|
3220
3310
|
const coverage = buildCoverageTargets(candidate.kind, files, runner);
|
|
3221
3311
|
const setupHints = await inferFlowSetupHints(root, files, candidate.kind);
|
|
3222
|
-
const selectors = await inferFlowSelectors(root, files, runner);
|
|
3312
|
+
const selectors = await inferFlowSelectors(root, files, runner, addedDiffText);
|
|
3223
3313
|
const flow = {
|
|
3224
3314
|
title: candidate.title,
|
|
3225
3315
|
reason: candidate.reason,
|
|
@@ -3238,9 +3328,12 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
|
|
|
3238
3328
|
languageBrief: buildFlowLanguageBrief(flow),
|
|
3239
3329
|
};
|
|
3240
3330
|
}
|
|
3331
|
+
function preferDiffAdded(selectors, predicate) {
|
|
3332
|
+
return selectors.find((selector) => selector.addedInDiff && predicate(selector)) ?? selectors.find(predicate);
|
|
3333
|
+
}
|
|
3241
3334
|
function refineStepsForInferredSelectors(steps, selectors) {
|
|
3242
|
-
const inputSelector = selectors
|
|
3243
|
-
const actionSelector = selectors
|
|
3335
|
+
const inputSelector = preferDiffAdded(selectors, isInputSelector);
|
|
3336
|
+
const actionSelector = preferDiffAdded(selectors, (selector) => selectorCanDriveInteraction(selector) && !isInputSelector(selector));
|
|
3244
3337
|
if (!inputSelector || !actionSelector || steps.some((step) => /^\s*(?:fill|input|enter|type|provide|write)\b/i.test(step))) {
|
|
3245
3338
|
return steps;
|
|
3246
3339
|
}
|
|
@@ -3257,8 +3350,8 @@ function refineStepsForInferredSelectors(steps, selectors) {
|
|
|
3257
3350
|
return uniqueStrings(refined);
|
|
3258
3351
|
}
|
|
3259
3352
|
function refineManifestStepsForInferredSelectors(steps, selectors) {
|
|
3260
|
-
const inputSelector = selectors
|
|
3261
|
-
const actionSelector = selectors
|
|
3353
|
+
const inputSelector = preferDiffAdded(selectors, isInputSelector);
|
|
3354
|
+
const actionSelector = preferDiffAdded(selectors, (selector) => selectorCanDriveInteraction(selector) && !isInputSelector(selector));
|
|
3262
3355
|
if (!inputSelector || !actionSelector || steps.some(isInputStep)) {
|
|
3263
3356
|
return steps;
|
|
3264
3357
|
}
|
|
@@ -3280,7 +3373,12 @@ function exerciseStepSubject(step) {
|
|
|
3280
3373
|
return undefined;
|
|
3281
3374
|
}
|
|
3282
3375
|
function selectorStepLabel(selector) {
|
|
3283
|
-
|
|
3376
|
+
const label = titleCase(selector.value.replace(/[-_]+/g, " "));
|
|
3377
|
+
if (label) {
|
|
3378
|
+
return label;
|
|
3379
|
+
}
|
|
3380
|
+
const raw = selector.value.trim();
|
|
3381
|
+
return raw.length > 0 ? `"${raw}"` : `the ${selector.kind} control`;
|
|
3284
3382
|
}
|
|
3285
3383
|
function actionVerbForSelector(selector) {
|
|
3286
3384
|
if (/\b(?:submit|send|apply|complete|confirm|save|continue|next|upload)\b/i.test(selector.value.replace(/[-_]+/g, " "))) {
|
|
@@ -3588,7 +3686,8 @@ function routeSegmentsFromFileParts(parts) {
|
|
|
3588
3686
|
function isBackendImplementationFile(file) {
|
|
3589
3687
|
return /(?:^|\/)(?:server|servers|backend|api|apis|routes|controllers?|handlers?|resolvers?|endpoints?)\//i.test(file) ||
|
|
3590
3688
|
/(?: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)
|
|
3689
|
+
/(?:^|\/)(?:urls|views|viewsets|serializers|routers|controllers?|handlers?|admin|models|services|tasks|permissions|filters|consumers|signals)\w*\.py$/i.test(file) ||
|
|
3690
|
+
/(?:^|\/)(?:views|viewsets|serializers|services|routers|handlers|tasks|consumers)\/[^/]+\.py$/i.test(file);
|
|
3592
3691
|
}
|
|
3593
3692
|
function isMockOrFixtureFile(file) {
|
|
3594
3693
|
if (isFixtureEvidenceIgnoredPath(file)) {
|
|
@@ -3665,7 +3764,7 @@ const fixtureEvidenceIgnoredTokens = new Set([
|
|
|
3665
3764
|
"test",
|
|
3666
3765
|
"tests",
|
|
3667
3766
|
]);
|
|
3668
|
-
async function inferFlowSelectors(root, files, runner) {
|
|
3767
|
+
async function inferFlowSelectors(root, files, runner, addedDiffText = {}) {
|
|
3669
3768
|
const selectors = [];
|
|
3670
3769
|
for (const file of files.slice(0, 8)) {
|
|
3671
3770
|
if (!isUiImplementationFile(file)) {
|
|
@@ -3675,7 +3774,10 @@ async function inferFlowSelectors(root, files, runner) {
|
|
|
3675
3774
|
if (!text) {
|
|
3676
3775
|
continue;
|
|
3677
3776
|
}
|
|
3678
|
-
|
|
3777
|
+
const addedText = addedDiffText[file];
|
|
3778
|
+
for (const selector of extractSelectorsFromText(file, text, runner)) {
|
|
3779
|
+
selectors.push(addedText && addedText.includes(selector.value) ? { ...selector, addedInDiff: true } : selector);
|
|
3780
|
+
}
|
|
3679
3781
|
}
|
|
3680
3782
|
return uniqueSelectors(selectors).slice(0, 12);
|
|
3681
3783
|
}
|
|
@@ -4085,7 +4187,8 @@ function isUiImplementationFile(file) {
|
|
|
4085
4187
|
}
|
|
4086
4188
|
function isServiceSourceFile(file) {
|
|
4087
4189
|
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)
|
|
4190
|
+
/(?:^|\/)(?:urls|views|viewsets|serializers|routers|controllers?|handlers?|admin|models|services|tasks|permissions|filters|consumers|signals)\w*\.py$/i.test(file) ||
|
|
4191
|
+
/(?:^|\/)(?:views|viewsets|serializers|services|api|apis|routers|handlers|tasks|consumers)\/[^/]+\.py$/i.test(file);
|
|
4089
4192
|
}
|
|
4090
4193
|
function summarizeFlowSubject(files, fallback, domainLanguage) {
|
|
4091
4194
|
const languageSubject = summarizeFlowSubjectFromDomainLanguage(files, domainLanguage);
|
|
@@ -4338,7 +4441,7 @@ function titleCase(value) {
|
|
|
4338
4441
|
return value
|
|
4339
4442
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
4340
4443
|
.replace(/[-_]+/g, " ")
|
|
4341
|
-
.replace(/[
|
|
4444
|
+
.replace(/[^\p{L}\p{N} ]+/gu, " ")
|
|
4342
4445
|
.trim()
|
|
4343
4446
|
.split(/\s+/)
|
|
4344
4447
|
.filter(Boolean)
|
|
@@ -6248,42 +6351,56 @@ function formatMaestroCommand(command) {
|
|
|
6248
6351
|
}
|
|
6249
6352
|
return [`- ${command.kind}: ${command.value}`];
|
|
6250
6353
|
}
|
|
6354
|
+
function takePreferredSelector(selectors, predicate, diffGate = predicate) {
|
|
6355
|
+
let firstIndex = -1;
|
|
6356
|
+
for (let index = 0; index < selectors.length; index += 1) {
|
|
6357
|
+
const selector = selectors[index];
|
|
6358
|
+
if (!predicate(selector)) {
|
|
6359
|
+
continue;
|
|
6360
|
+
}
|
|
6361
|
+
if (selector.addedInDiff && diffGate(selector)) {
|
|
6362
|
+
return selectors.splice(index, 1)[0];
|
|
6363
|
+
}
|
|
6364
|
+
if (firstIndex === -1) {
|
|
6365
|
+
firstIndex = index;
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
return firstIndex >= 0 ? selectors.splice(firstIndex, 1)[0] : undefined;
|
|
6369
|
+
}
|
|
6251
6370
|
function takeSelectorForStep(selectors, step) {
|
|
6252
6371
|
if (/^launch\b/i.test(step)) {
|
|
6253
6372
|
return undefined;
|
|
6254
6373
|
}
|
|
6255
6374
|
if (isInputStep(step)) {
|
|
6256
|
-
const
|
|
6257
|
-
if (
|
|
6258
|
-
|
|
6259
|
-
return selector;
|
|
6375
|
+
const matched = takePreferredSelector(selectors, (selector) => isInputSelector(selector) && selectorMatchesStep(selector, step));
|
|
6376
|
+
if (matched) {
|
|
6377
|
+
return matched;
|
|
6260
6378
|
}
|
|
6261
|
-
const
|
|
6262
|
-
if (
|
|
6263
|
-
|
|
6264
|
-
return selector;
|
|
6379
|
+
const fallbackInput = takePreferredSelector(selectors, isInputSelector);
|
|
6380
|
+
if (fallbackInput) {
|
|
6381
|
+
return fallbackInput;
|
|
6265
6382
|
}
|
|
6266
6383
|
}
|
|
6267
6384
|
if (!isInteractionStep(step) && !isVerificationStep(step) && !canUsePrimarySelector(step)) {
|
|
6268
6385
|
return undefined;
|
|
6269
6386
|
}
|
|
6270
|
-
const
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6387
|
+
const diffGateForStep = isAssertionStep(step) || isVerificationStep(step)
|
|
6388
|
+
? selectorCanSupportAssertion
|
|
6389
|
+
: selectorCanDriveInteraction;
|
|
6390
|
+
const matched = takePreferredSelector(selectors, (selector) => !isInputSelector(selector) && selectorMatchesStep(selector, step), diffGateForStep);
|
|
6391
|
+
if (matched) {
|
|
6392
|
+
return matched;
|
|
6274
6393
|
}
|
|
6275
6394
|
if (isAssertionStep(step)) {
|
|
6276
|
-
const
|
|
6277
|
-
if (
|
|
6278
|
-
|
|
6279
|
-
return selector;
|
|
6395
|
+
const assertion = takePreferredSelector(selectors, selectorCanSupportAssertion);
|
|
6396
|
+
if (assertion) {
|
|
6397
|
+
return assertion;
|
|
6280
6398
|
}
|
|
6281
6399
|
}
|
|
6282
6400
|
if (canUsePrimarySelector(step)) {
|
|
6283
|
-
const
|
|
6284
|
-
if (
|
|
6285
|
-
|
|
6286
|
-
return selector;
|
|
6401
|
+
const fallback = takePreferredSelector(selectors, selectorCanDriveInteraction);
|
|
6402
|
+
if (fallback) {
|
|
6403
|
+
return fallback;
|
|
6287
6404
|
}
|
|
6288
6405
|
}
|
|
6289
6406
|
return undefined;
|
|
@@ -6654,6 +6771,77 @@ async function readPackageJson(root) {
|
|
|
6654
6771
|
return undefined;
|
|
6655
6772
|
}
|
|
6656
6773
|
}
|
|
6774
|
+
const maxWorkspaceMembers = 30;
|
|
6775
|
+
async function collectWorkspaceMemberDependencies(root, packageJson) {
|
|
6776
|
+
const patterns = await readWorkspaceMemberPatterns(root, packageJson);
|
|
6777
|
+
if (patterns.length === 0) {
|
|
6778
|
+
return [];
|
|
6779
|
+
}
|
|
6780
|
+
const memberDirectories = await expandWorkspaceMemberPatterns(root, patterns);
|
|
6781
|
+
const members = [];
|
|
6782
|
+
for (const directory of memberDirectories.slice(0, maxWorkspaceMembers)) {
|
|
6783
|
+
const memberPackageJson = await readPackageJson(path.join(root, directory));
|
|
6784
|
+
if (!memberPackageJson) {
|
|
6785
|
+
continue;
|
|
6786
|
+
}
|
|
6787
|
+
const dependencies = {
|
|
6788
|
+
...(memberPackageJson.dependencies ?? {}),
|
|
6789
|
+
...(memberPackageJson.devDependencies ?? {}),
|
|
6790
|
+
};
|
|
6791
|
+
members.push({
|
|
6792
|
+
directory,
|
|
6793
|
+
dependencies,
|
|
6794
|
+
frameworkSignals: frameworkSignalDependencies.filter((dependency) => dependency in dependencies),
|
|
6795
|
+
});
|
|
6796
|
+
}
|
|
6797
|
+
return members;
|
|
6798
|
+
}
|
|
6799
|
+
async function readWorkspaceMemberPatterns(root, packageJson) {
|
|
6800
|
+
const patterns = [];
|
|
6801
|
+
const workspaces = packageJson?.workspaces;
|
|
6802
|
+
if (Array.isArray(workspaces)) {
|
|
6803
|
+
patterns.push(...workspaces);
|
|
6804
|
+
}
|
|
6805
|
+
else if (workspaces?.packages) {
|
|
6806
|
+
patterns.push(...workspaces.packages);
|
|
6807
|
+
}
|
|
6808
|
+
const workspaceYaml = await readTextIfExists(path.join(root, "pnpm-workspace.yaml"));
|
|
6809
|
+
if (workspaceYaml) {
|
|
6810
|
+
try {
|
|
6811
|
+
const parsed = YAML.parse(workspaceYaml);
|
|
6812
|
+
patterns.push(...(parsed?.packages ?? []));
|
|
6813
|
+
}
|
|
6814
|
+
catch {
|
|
6815
|
+
// ignore unparseable workspace config
|
|
6816
|
+
}
|
|
6817
|
+
}
|
|
6818
|
+
return uniqueStrings(patterns.filter((pattern) => typeof pattern === "string" && !pattern.startsWith("!")));
|
|
6819
|
+
}
|
|
6820
|
+
async function expandWorkspaceMemberPatterns(root, patterns) {
|
|
6821
|
+
const directories = [];
|
|
6822
|
+
for (const pattern of patterns) {
|
|
6823
|
+
const normalized = pattern.replace(/\/\*{1,2}$/, "");
|
|
6824
|
+
if (normalized.includes("*")) {
|
|
6825
|
+
continue;
|
|
6826
|
+
}
|
|
6827
|
+
if (normalized === pattern) {
|
|
6828
|
+
directories.push(normalized);
|
|
6829
|
+
continue;
|
|
6830
|
+
}
|
|
6831
|
+
try {
|
|
6832
|
+
const entries = await fs.readdir(path.join(root, normalized), { withFileTypes: true });
|
|
6833
|
+
for (const entry of entries) {
|
|
6834
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
6835
|
+
directories.push(`${normalized}/${entry.name}`);
|
|
6836
|
+
}
|
|
6837
|
+
}
|
|
6838
|
+
}
|
|
6839
|
+
catch {
|
|
6840
|
+
continue;
|
|
6841
|
+
}
|
|
6842
|
+
}
|
|
6843
|
+
return uniqueStrings(directories);
|
|
6844
|
+
}
|
|
6657
6845
|
async function readTextIfExists(filePath) {
|
|
6658
6846
|
try {
|
|
6659
6847
|
return await fs.readFile(filePath, "utf8");
|