@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/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 coreFlows = matchCoreFlows(coreFlowManifest, coreFlowChangedFiles);
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, coreFlowChangedFiles);
42
+ const domains = matchDomains(domainManifest, matchableChangedFiles);
40
43
  const verificationManifest = await loadVerificationManifest(coreFlowRoot, { manifestPath: options.manifestPath });
41
- const verificationManifestMatches = matchVerificationManifest(verificationManifest, coreFlowChangedFiles);
42
- const domainLanguage = await buildDomainLanguageSummary(root, testPlan.changedFiles, coreFlows, domains);
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 buildFlowCandidates(files, runner, projectType, domainLanguage) {
2953
- const lowSignalCandidate = buildLowSignalChangeCandidate(files);
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 uiFiles = candidateFiles.filter(isUserFacingFile);
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: "User-facing route, screen, navigation, or component files changed, so the draft should open the touched surface and cover the primary visible action.",
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.find(isInputSelector);
3243
- const actionSelector = selectors.find((selector) => selectorCanDriveInteraction(selector) && !isInputSelector(selector));
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.find(isInputSelector);
3261
- const actionSelector = selectors.find((selector) => selectorCanDriveInteraction(selector) && !isInputSelector(selector));
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
- return titleCase(selector.value.replace(/[-_]+/g, " "));
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)\.py$/i.test(file);
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
- selectors.push(...extractSelectorsFromText(file, text, runner));
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)\.py$/i.test(file);
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(/[^a-zA-Z0-9 ]+/g, " ")
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 changedEndpointHints = uniqueStrings(flow.fixtureReadiness.backendSignals.flatMap(apiEndpointFromBackendFile));
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 inputIndex = selectors.findIndex((selector) => isInputSelector(selector) && selectorMatchesStep(selector, step));
6257
- if (inputIndex >= 0) {
6258
- const [selector] = selectors.splice(inputIndex, 1);
6259
- return selector;
6437
+ const matched = takePreferredSelector(selectors, (selector) => isInputSelector(selector) && selectorMatchesStep(selector, step));
6438
+ if (matched) {
6439
+ return matched;
6260
6440
  }
6261
- const fallbackInputIndex = selectors.findIndex(isInputSelector);
6262
- if (fallbackInputIndex >= 0) {
6263
- const [selector] = selectors.splice(fallbackInputIndex, 1);
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 index = selectors.findIndex((selector) => !isInputSelector(selector) && selectorMatchesStep(selector, step));
6271
- if (index >= 0) {
6272
- const [selector] = selectors.splice(index, 1);
6273
- return selector;
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 assertionIndex = selectors.findIndex((selector) => selectorCanSupportAssertion(selector));
6277
- if (assertionIndex >= 0) {
6278
- const [selector] = selectors.splice(assertionIndex, 1);
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 fallbackIndex = selectors.findIndex((selector) => selectorCanDriveInteraction(selector));
6284
- if (fallbackIndex >= 0) {
6285
- const [selector] = selectors.splice(fallbackIndex, 1);
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(`${escapeRegExp(attribute)}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{\\s*["'\`]([^"'\`{}]+)["'\`]\\s*\\})`, "g");
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
- return value.length >= 2 && value.length <= 80 && !/[{}()[\]=>]/.test(value);
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");