@ivorycanvas/qamap 0.3.2 → 0.3.4
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 +56 -0
- package/README.md +44 -578
- package/dist/agent-init.d.ts +16 -0
- package/dist/agent-init.js +110 -0
- package/dist/agent-init.js.map +1 -0
- package/dist/cli.js +17 -1
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +1 -0
- package/dist/context.js +4 -0
- package/dist/context.js.map +1 -1
- package/dist/domain-language.js +121 -12
- package/dist/domain-language.js.map +1 -1
- package/dist/e2e.d.ts +4 -0
- package/dist/e2e.js +424 -85
- package/dist/e2e.js.map +1 -1
- package/dist/fixture-insight.d.ts +8 -0
- package/dist/fixture-insight.js +193 -0
- package/dist/fixture-insight.js.map +1 -0
- package/dist/fs.js +11 -0
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +5 -0
- package/dist/manifest.js +478 -54
- package/dist/manifest.js.map +1 -1
- package/dist/qa.d.ts +4 -0
- package/dist/qa.js +177 -14
- package/dist/qa.js.map +1 -1
- package/dist/terminal.d.ts +2 -0
- package/dist/terminal.js +51 -0
- package/dist/terminal.js.map +1 -0
- package/dist/test-plan.js +56 -6
- package/dist/test-plan.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adoption.md +62 -0
- package/docs/agent-format.md +52 -0
- package/docs/agent-skill.md +17 -1
- package/docs/benchmarking.md +60 -20
- package/docs/commands.md +242 -0
- package/docs/configuration.md +11 -0
- package/docs/e2e-output-examples.md +8 -4
- package/docs/guardrails.md +64 -0
- package/docs/manifest.md +3 -3
- package/docs/quickstart-demo.md +1 -1
- package/docs/release-validation.md +31 -4
- package/docs/releasing.md +13 -10
- package/docs/roadmap.md +9 -14
- package/package.json +4 -3
- package/schema/qamap-agent.schema.json +171 -0
- package/skills/qamap-pr-qa/SKILL.md +2 -1
package/dist/e2e.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { buildDomainLanguageSummary } from "./domain-language.js";
|
|
5
5
|
import { defaultDomainManifestPath, loadDomainManifest, matchDomains } from "./domains.js";
|
|
6
|
+
import { analyzeFixtureSource, insightCoversEndpoint } from "./fixture-insight.js";
|
|
6
7
|
import { collectProjectFiles } from "./fs.js";
|
|
7
8
|
import { buildReverseImportIndex, expandChangedFilesWithImporters, findImportingSurfaces } from "./import-graph.js";
|
|
8
9
|
import { loadCoreFlowManifest, matchCoreFlows } from "./flows.js";
|
|
@@ -10,6 +11,19 @@ import { loadVerificationManifest, matchVerificationManifest } from "./manifest.
|
|
|
10
11
|
import { collectTestSuiteInventory, evaluateFlowCoverageEvidence, summarizeTestSuiteInventory, } from "./test-evidence.js";
|
|
11
12
|
import { collectAddedDiffText, generateTestPlan } from "./test-plan.js";
|
|
12
13
|
import { TOOL_NAME, VERSION } from "./version.js";
|
|
14
|
+
// Human reports render readiness as a stage on a fixed journey instead of a
|
|
15
|
+
// verdict, so a first run reads as "you are at the start", not "you failed".
|
|
16
|
+
// JSON/agent output keeps the raw level values as the stable contract.
|
|
17
|
+
const draftReadinessStages = {
|
|
18
|
+
blocked: { position: 1, label: "setup needed" },
|
|
19
|
+
"needs-work": { position: 2, label: "draft in progress" },
|
|
20
|
+
"near-runnable": { position: 3, label: "almost runnable" },
|
|
21
|
+
ready: { position: 4, label: "ready to run" },
|
|
22
|
+
};
|
|
23
|
+
export function formatDraftReadinessStage(summary) {
|
|
24
|
+
const stage = draftReadinessStages[summary.level];
|
|
25
|
+
return `${stage.label} (${stage.position} of 4) — readiness ${summary.score}/100`;
|
|
26
|
+
}
|
|
13
27
|
const maxFilesPerFlow = 8;
|
|
14
28
|
const workspacePackageSearchLimit = 200;
|
|
15
29
|
const workspacePackageIgnoredDirectories = new Set([
|
|
@@ -30,7 +44,10 @@ export async function generateE2ePlan(rootInput, options = {}) {
|
|
|
30
44
|
const root = path.resolve(rootInput);
|
|
31
45
|
const testPlan = await generateTestPlan(root, options);
|
|
32
46
|
const project = await detectProjectProfile(root, testPlan.workspaceRoot);
|
|
33
|
-
const
|
|
47
|
+
const changedPaths = testPlan.changedFiles.map((file) => file.path);
|
|
48
|
+
const recommendedRunner = options.runner
|
|
49
|
+
? overrideRunner(project, options.runner)
|
|
50
|
+
: recommendRunnerForChange(project, changedPaths);
|
|
34
51
|
const executionProfile = await buildExecutionProfile(root, testPlan.workspaceRoot, project, recommendedRunner.name);
|
|
35
52
|
const testSuiteInventory = await collectTestSuiteInventory(root);
|
|
36
53
|
const coreFlowRoot = testPlan.workspaceRoot ?? root;
|
|
@@ -114,6 +131,12 @@ export async function generateE2ePlan(rootInput, options = {}) {
|
|
|
114
131
|
export async function generateE2eDraft(rootInput, options = {}) {
|
|
115
132
|
const root = path.resolve(rootInput);
|
|
116
133
|
const plan = await generateE2ePlan(root, options);
|
|
134
|
+
const addedDiffText = await collectAddedDiffText(root, {
|
|
135
|
+
base: plan.base,
|
|
136
|
+
head: plan.head,
|
|
137
|
+
workspaceRoot: plan.workspaceRoot,
|
|
138
|
+
includeWorkingTree: options.includeWorkingTree,
|
|
139
|
+
});
|
|
117
140
|
const runner = plan.recommendedRunner.name;
|
|
118
141
|
const outputDirectory = path.resolve(root, options.output ?? defaultDraftOutputDirectory(runner));
|
|
119
142
|
const draftLimit = options.maxDrafts && options.maxDrafts > 0 ? Math.floor(options.maxDrafts) : undefined;
|
|
@@ -130,7 +153,7 @@ export async function generateE2eDraft(rootInput, options = {}) {
|
|
|
130
153
|
const promotionGuidance = buildDraftPromotionGuidance(flow);
|
|
131
154
|
const fileAlreadyExists = await exists(filePath);
|
|
132
155
|
const shouldSkip = fileAlreadyExists && !options.force && !dryRun;
|
|
133
|
-
const content = shouldSkip ? ((await readTextIfExists(filePath)) ?? "") : draftContentForFlow(plan, flow, runner);
|
|
156
|
+
const content = shouldSkip ? ((await readTextIfExists(filePath)) ?? "") : draftContentForFlow(plan, flow, runner, addedDiffText);
|
|
134
157
|
const todoCount = countTodos(content);
|
|
135
158
|
const selfCheck = evaluateDraftSelfCheck(plan, flow, runner, content, todoCount);
|
|
136
159
|
const actionItems = buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck);
|
|
@@ -453,6 +476,7 @@ function buildE2eValidationMatrix(flows, coreFlows) {
|
|
|
453
476
|
}
|
|
454
477
|
function buildE2eBootstrapPlan(input) {
|
|
455
478
|
const steps = [];
|
|
479
|
+
const verificationOnly = input.flows.length > 0 && input.flows.every(isVerificationOnlyFlow);
|
|
456
480
|
const runnerGap = input.missingTestability.find((gap) => /No \.maestro|No Playwright config/i.test(gap));
|
|
457
481
|
const draftCommand = `qamap e2e draft . --base ${input.base} --head ${input.head}`;
|
|
458
482
|
const planHistoryCommand = `qamap e2e plan . --base ${input.base} --head ${input.head} --record-history`;
|
|
@@ -465,7 +489,10 @@ function buildE2eBootstrapPlan(input) {
|
|
|
465
489
|
? `${concreteTargets.length} changed workspace target${concreteTargets.length === 1 ? "" : "s"} have clearer app or service signals than the workspace root.`
|
|
466
490
|
: "Changed files map to workspace packages, but their app surface still needs a package-scoped review.", "Run the suggested package-scoped plan commands, then generate drafts from the package whose changed flow is user-facing.", input.workspaceTargets.map((target) => target.suggestedCommand).slice(0, 4), input.workspaceTargets.flatMap((target) => target.changedFiles).slice(0, maxFilesPerFlow)));
|
|
467
491
|
}
|
|
468
|
-
if (
|
|
492
|
+
if (verificationOnly) {
|
|
493
|
+
steps.push(bootstrapStep("runner", "ready", "Use repository validation for this verification-only change", "The diff maps to configuration, documentation, generated output, or existing test evidence rather than a new product journey.", "Run the nearest repository validation command and attach the result as PR evidence; no UI runner setup is required by this diff alone.", input.suggestedCommands.slice(0, 4), input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
|
|
494
|
+
}
|
|
495
|
+
else if (input.recommendedRunner.name === "manual") {
|
|
469
496
|
steps.push(bootstrapStep("runner", "required", manualBootstrapTitle(input.projectType), manualBootstrapReason(input.projectType), manualBootstrapAction(input.projectType), [], []));
|
|
470
497
|
}
|
|
471
498
|
else if (runnerGap) {
|
|
@@ -481,27 +508,30 @@ function buildE2eBootstrapPlan(input) {
|
|
|
481
508
|
else {
|
|
482
509
|
steps.push(bootstrapStep("runner", "ready", `${formatRunnerName(input.recommendedRunner.name)} setup signal detected`, "QAMap found enough runner setup evidence to generate runnable drafts.", "Keep runner setup documented and linked from PR validation notes.", [], []));
|
|
483
510
|
}
|
|
484
|
-
if (input.recommendedRunner.name !== "manual" && executionProfileBlockers.length > 0) {
|
|
511
|
+
if (!verificationOnly && input.recommendedRunner.name !== "manual" && executionProfileBlockers.length > 0) {
|
|
485
512
|
steps.push(bootstrapStep("runner", "required", "Complete the E2E execution profile", executionProfileBlockers.slice(0, 3).join(" "), "Document or configure the missing command, URL, app id, or runner file before treating generated drafts as runnable regression coverage.", uniqueStrings([input.executionProfile.startCommand, input.executionProfile.testCommand].filter(Boolean)), input.executionProfile.configFiles));
|
|
486
513
|
}
|
|
487
|
-
if (
|
|
514
|
+
if (verificationOnly) {
|
|
515
|
+
steps.push(bootstrapStep("draft", "ready", "No new E2E draft required for this change", "The changed files are verification inputs or existing evidence, so generating another product-journey test would duplicate or invent coverage.", "Run the changed evidence or nearest build, lint, typecheck, documentation, or artifact validation command and record its result.", input.suggestedCommands.slice(0, 4), input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
|
|
516
|
+
}
|
|
517
|
+
else if (!input.testSuite.hasTestSuite) {
|
|
488
518
|
steps.push(bootstrapStep("draft", "required", "Create the first changed-flow E2E draft", "No existing test files were detected, so this branch needs a concrete first draft before QAMap can compare coverage evidence.", "Generate the first draft, replace TODO selectors and setup values, then decide which paths should become required regression coverage.", [draftCommand], input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
|
|
489
519
|
}
|
|
490
520
|
else {
|
|
491
521
|
steps.push(bootstrapStep("draft", "ready", "Existing test suite detected", `${input.testSuite.testFileCount} test file${input.testSuite.testFileCount === 1 ? "" : "s"} can be used as coverage evidence.`, "Use the validation matrix to expand existing tests or generated drafts only where evidence is weak.", [], []));
|
|
492
522
|
}
|
|
493
|
-
if (!input.domainManifestPath && input.domainLanguage.terms.length > 0) {
|
|
523
|
+
if (!verificationOnly && !input.domainManifestPath && input.domainLanguage.terms.length > 0) {
|
|
494
524
|
steps.push(bootstrapStep("domain-language", "recommended", "Promote repeated product words into a domain manifest", `QAMap inferred ${input.domainLanguage.terms.length} domain term${input.domainLanguage.terms.length === 1 ? "" : "s"} from changed files, but no shared domain manifest was found.`, "Generate a suggested domain manifest from this branch, review names and routes with the team, then commit only the terms that match team language.", [domainsSuggestCommand, `${domainsSuggestCommand} --write .qamap/domains.yml`], input.domainLanguage.terms.flatMap((term) => term.files).slice(0, maxFilesPerFlow)));
|
|
495
525
|
}
|
|
496
|
-
else if (input.domainManifestPath || input.domains.length > 0) {
|
|
526
|
+
else if (!verificationOnly && (input.domainManifestPath || input.domains.length > 0)) {
|
|
497
527
|
steps.push(bootstrapStep("domain-language", "ready", "Domain language has shared policy evidence", input.domainManifestPath
|
|
498
528
|
? `Domain manifest found at ${input.domainManifestPath}.`
|
|
499
529
|
: "Matched domain definitions were found.", "Keep committed domain language aligned with the names used in generated E2E drafts.", [], []));
|
|
500
530
|
}
|
|
501
|
-
if (!input.coreFlowManifestPath) {
|
|
531
|
+
if (!verificationOnly && !input.coreFlowManifestPath) {
|
|
502
532
|
steps.push(bootstrapStep("core-flow", input.flows.length > 0 ? "recommended" : "required", "Capture the first durable core flows", "No .qamap/flows.yml manifest was found, so QAMap can infer changed-flow candidates but cannot distinguish team-critical journeys yet.", "Generate suggested flow entries from this branch, keep only the journeys humans agree are durable, then commit them as team policy.", [flowsSuggestCommand, `${flowsSuggestCommand} --write .qamap/flows.yml`], input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
|
|
503
533
|
}
|
|
504
|
-
else {
|
|
534
|
+
else if (!verificationOnly) {
|
|
505
535
|
steps.push(bootstrapStep("core-flow", input.coreFlows.length > 0 ? "ready" : "recommended", input.coreFlows.length > 0 ? "Core flow manifest matched this change" : "Review core flow manifest coverage", input.coreFlows.length > 0
|
|
506
536
|
? `${input.coreFlows.length} declared core flow${input.coreFlows.length === 1 ? "" : "s"} matched the changed files.`
|
|
507
537
|
: `Core flow manifest found at ${input.coreFlowManifestPath}, but this change did not match a declared flow.`, input.coreFlows.length > 0
|
|
@@ -521,24 +551,30 @@ function buildE2eBootstrapPlan(input) {
|
|
|
521
551
|
: `${partialFixtureRows.length} API-dependent flows have only partial fixture evidence.`, "Cover success plus one empty, unauthorized, timeout, rejected, or server-error response before requiring the generated draft.", [], uniqueStrings([...missingFixtureRows, ...partialFixtureRows].flatMap((row) => row.files)).slice(0, maxFilesPerFlow)));
|
|
522
552
|
}
|
|
523
553
|
const nonRunnerTestabilityGaps = input.missingTestability.filter((gap) => !/No \.maestro|No Playwright config/i.test(gap));
|
|
524
|
-
if (nonRunnerTestabilityGaps.length > 0) {
|
|
554
|
+
if (!verificationOnly && nonRunnerTestabilityGaps.length > 0) {
|
|
525
555
|
steps.push(bootstrapStep("testability", "required", "Add stable selectors for changed user actions", `${nonRunnerTestabilityGaps.length} selector or interaction gap${nonRunnerTestabilityGaps.length === 1 ? "" : "s"} were detected.`, "Add stable test ids, accessibility labels, roles, or durable visible copy for the controls the draft must tap, type into, or assert.", [], input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
|
|
526
556
|
}
|
|
527
|
-
else if (input.flows.some((flow) => flow.selectors.length > 0 || flow.entrypoints.length > 0)) {
|
|
557
|
+
else if (!verificationOnly && input.flows.some((flow) => flow.selectors.length > 0 || flow.entrypoints.length > 0)) {
|
|
528
558
|
steps.push(bootstrapStep("testability", "ready", "Entrypoint or selector evidence detected", "Generated drafts can reuse detected routes, screens, selectors, labels, or visible copy.", "Review the generated locators and replace weak starter assertions with domain-specific checks before promoting the draft.", [], input.flows.flatMap((flow) => flow.selectors.map((selector) => selector.file)).slice(0, maxFilesPerFlow)));
|
|
529
559
|
}
|
|
530
560
|
if (input.validationMatrix.summary.missing > 0) {
|
|
531
561
|
steps.push(bootstrapStep("validation", "required", "Close missing validation matrix rows", input.validationMatrix.summary.missing === 1
|
|
532
562
|
? "1 validation row is missing evidence."
|
|
533
|
-
: `${input.validationMatrix.summary.missing} validation rows are missing evidence.`,
|
|
563
|
+
: `${input.validationMatrix.summary.missing} validation rows are missing evidence.`, verificationOnly
|
|
564
|
+
? "Run or record the missing repository validation checks before treating this change as verified."
|
|
565
|
+
: "Resolve missing coverage, fixture, setup, and testability rows before calling generated E2E coverage sufficient.", [], input.validationMatrix.rows.filter((row) => row.status === "missing").flatMap((row) => row.files).slice(0, maxFilesPerFlow)));
|
|
534
566
|
}
|
|
535
567
|
else if (input.validationMatrix.summary.partial > 0) {
|
|
536
568
|
steps.push(bootstrapStep("validation", "recommended", "Strengthen partial validation evidence", input.validationMatrix.summary.partial === 1
|
|
537
569
|
? "1 validation row is only partial."
|
|
538
|
-
: `${input.validationMatrix.summary.partial} validation rows are only partial.`,
|
|
570
|
+
: `${input.validationMatrix.summary.partial} validation rows are only partial.`, verificationOnly
|
|
571
|
+
? "Run or record the partial repository checks until each critical row has concrete evidence."
|
|
572
|
+
: "Expand the generated draft or existing tests until the critical rows have concrete evidence.", [], input.validationMatrix.rows.filter((row) => row.status === "partial").flatMap((row) => row.files).slice(0, maxFilesPerFlow)));
|
|
539
573
|
}
|
|
540
574
|
else if (input.validationMatrix.rows.length > 0) {
|
|
541
|
-
steps.push(bootstrapStep("validation", "ready", "Validation matrix is ready", "All validation matrix rows currently have ready evidence.",
|
|
575
|
+
steps.push(bootstrapStep("validation", "ready", "Validation matrix is ready", "All validation matrix rows currently have ready evidence.", verificationOnly
|
|
576
|
+
? "Keep the completed repository checks linked in PR evidence."
|
|
577
|
+
: "Keep the matrix linked in PR evidence when the generated draft is promoted.", [], []));
|
|
542
578
|
}
|
|
543
579
|
steps.push(bootstrapStep("history", "recommended", "Record local plan history while iterating", "Local run history lets the team compare how domain language, core flows, fixtures, and validation gaps evolve without spending more agent tokens.", "Run the plan with --record-history after important draft or manifest changes.", [planHistoryCommand], []));
|
|
544
580
|
if (input.suggestedCommands.length === 0) {
|
|
@@ -551,7 +587,7 @@ function buildE2eBootstrapPlan(input) {
|
|
|
551
587
|
ready: sortedSteps.filter((step) => step.status === "ready").length,
|
|
552
588
|
};
|
|
553
589
|
return {
|
|
554
|
-
summary: bootstrapSummary(counts, sortedSteps),
|
|
590
|
+
summary: bootstrapSummary(counts, sortedSteps, verificationOnly),
|
|
555
591
|
steps: sortedSteps,
|
|
556
592
|
counts,
|
|
557
593
|
};
|
|
@@ -655,9 +691,12 @@ function bootstrapCategoryRank(category) {
|
|
|
655
691
|
];
|
|
656
692
|
return order.indexOf(category);
|
|
657
693
|
}
|
|
658
|
-
function bootstrapSummary(counts, steps) {
|
|
694
|
+
function bootstrapSummary(counts, steps, verificationOnly = false) {
|
|
659
695
|
const firstRequired = steps.find((step) => step.status === "required");
|
|
660
696
|
if (firstRequired) {
|
|
697
|
+
if (verificationOnly) {
|
|
698
|
+
return `${counts.required} required verification step${counts.required === 1 ? "" : "s"} must be resolved before this change has repeatable PR evidence. Start with: ${firstRequired.title}.`;
|
|
699
|
+
}
|
|
661
700
|
return `${counts.required} required bootstrap step${counts.required === 1 ? "" : "s"} must be resolved before generated E2E drafts should be treated as regression coverage. Start with: ${firstRequired.title}.`;
|
|
662
701
|
}
|
|
663
702
|
const firstRecommended = steps.find((step) => step.status === "recommended");
|
|
@@ -916,6 +955,7 @@ function buildDraftPromotionGuidance(flow) {
|
|
|
916
955
|
}
|
|
917
956
|
function buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck) {
|
|
918
957
|
const items = [];
|
|
958
|
+
const verificationOnly = isVerificationOnlyFlow(flow);
|
|
919
959
|
const runnerGap = runnerSetupGap(plan, runner);
|
|
920
960
|
if (runnerGap) {
|
|
921
961
|
const setupCommand = plan.runnerSetup.setupCommand ? ` Run \`${plan.runnerSetup.setupCommand}\` after accepting this runner setup.` : "";
|
|
@@ -925,7 +965,7 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
|
|
|
925
965
|
items.push(draftActionItem("runner", "required", `Configure ${formatRunnerName(runner)} execution`, setupDetail));
|
|
926
966
|
}
|
|
927
967
|
const executionBlockers = remainingExecutionProfileBlockers(plan.executionProfile.blockers, runnerGap);
|
|
928
|
-
if (runner !== "manual" && executionBlockers.length > 0) {
|
|
968
|
+
if (!verificationOnly && runner !== "manual" && executionBlockers.length > 0) {
|
|
929
969
|
items.push(draftActionItem("runner", "required", "Complete execution profile", executionBlockers.slice(0, 3).join(" ")));
|
|
930
970
|
}
|
|
931
971
|
const route = primaryRouteEntrypoint(flow);
|
|
@@ -936,34 +976,42 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
|
|
|
936
976
|
items.push(draftActionItem("fixture", "required", "Replace dynamic route parameters", `Provide real fixture values for ${unresolvedParams.map((param) => param.name).join(", ")} before running ${route.value}.`));
|
|
937
977
|
}
|
|
938
978
|
}
|
|
979
|
+
// Endpoints ride in the title because the agent format keeps only titles.
|
|
980
|
+
const fixtureEndpointSuffix = flow.fixtureReadiness.apiEndpoints.length > 0
|
|
981
|
+
? ` for ${formatEndpointSummary(flow.fixtureReadiness.apiEndpoints)}`
|
|
982
|
+
: "";
|
|
939
983
|
if (flow.fixtureReadiness.status === "missing") {
|
|
940
|
-
items.push(draftActionItem("fixture", "required",
|
|
984
|
+
items.push(draftActionItem("fixture", "required", `Add deterministic fixture or mock data${fixtureEndpointSuffix}`, flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason));
|
|
941
985
|
}
|
|
942
986
|
else if (flow.fixtureReadiness.status === "partial") {
|
|
943
|
-
items.push(draftActionItem("fixture", "recommended",
|
|
987
|
+
items.push(draftActionItem("fixture", "recommended", `Confirm fixture coverage${fixtureEndpointSuffix}`, flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason));
|
|
944
988
|
}
|
|
945
|
-
if (flow.missingTestability.length > 0) {
|
|
989
|
+
if (!verificationOnly && flow.missingTestability.length > 0) {
|
|
946
990
|
items.push(draftActionItem("selector", "required", "Replace weak or missing selectors", flow.missingTestability[0]));
|
|
947
991
|
}
|
|
948
|
-
else if (flow.selectors.length === 0 && runner !== "manual") {
|
|
992
|
+
else if (!verificationOnly && flow.selectors.length === 0 && runner !== "manual") {
|
|
949
993
|
items.push(draftActionItem("selector", "recommended", "Confirm stable selectors", "No stable selector hints were inferred, so review the generated locators before making this draft required."));
|
|
950
994
|
}
|
|
951
|
-
if (flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
|
|
995
|
+
if (!verificationOnly && flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
|
|
952
996
|
items.push(draftActionItem("assertion", "required", "Replace starter smoke assertions with domain assertions", `Preserve the success signal "${flow.languageBrief.successSignal}" while replacing weak fallback interactions and generic expects.`));
|
|
953
997
|
}
|
|
954
|
-
if (selfCheck?.status === "fail") {
|
|
998
|
+
if (!verificationOnly && selfCheck?.status === "fail") {
|
|
955
999
|
items.push(draftActionItem("validation", "required", "Resolve generated draft self-check", selfCheck.blockers[0] ?? selfCheck.summary));
|
|
956
1000
|
}
|
|
957
|
-
else if (selfCheck?.status === "warning") {
|
|
1001
|
+
else if (!verificationOnly && selfCheck?.status === "warning") {
|
|
958
1002
|
items.push(draftActionItem("validation", "recommended", "Review generated draft self-check", selfCheck.summary));
|
|
959
1003
|
}
|
|
960
1004
|
if (validationSummary.blockingGapCount > 0) {
|
|
961
|
-
items.push(draftActionItem("validation", "required", "Resolve missing validation evidence",
|
|
1005
|
+
items.push(draftActionItem("validation", "required", "Resolve missing validation evidence", verificationOnly
|
|
1006
|
+
? `${validationSummary.blockingGapCount} blocking validation gap${validationSummary.blockingGapCount === 1 ? "" : "s"} must be closed before treating this change as verified PR evidence.`
|
|
1007
|
+
: `${validationSummary.blockingGapCount} blocking validation gap${validationSummary.blockingGapCount === 1 ? "" : "s"} must be closed before using this draft as PR evidence.`));
|
|
962
1008
|
}
|
|
963
1009
|
else if (validationSummary.gapCount > 0) {
|
|
964
|
-
items.push(draftActionItem("validation", "recommended", "Close partial validation evidence",
|
|
1010
|
+
items.push(draftActionItem("validation", "recommended", "Close partial validation evidence", verificationOnly
|
|
1011
|
+
? `${validationSummary.gapCount} validation gap${validationSummary.gapCount === 1 ? "" : "s"} remain before this change has stable verification evidence.`
|
|
1012
|
+
: `${validationSummary.gapCount} validation gap${validationSummary.gapCount === 1 ? "" : "s"} remain before this draft is stable regression coverage.`));
|
|
965
1013
|
}
|
|
966
|
-
if (promotionGuidance.status !== "commit-candidate") {
|
|
1014
|
+
if (!verificationOnly && promotionGuidance.status !== "commit-candidate") {
|
|
967
1015
|
items.push(draftActionItem("manifest", "recommended", "Promote durable product language", promotionGuidance.action));
|
|
968
1016
|
}
|
|
969
1017
|
return uniqueDraftActionItems(items).slice(0, 8);
|
|
@@ -1171,8 +1219,8 @@ function draftReadinessRecommendation(level, blockers) {
|
|
|
1171
1219
|
: "Close required action items before treating the drafts as regression evidence.";
|
|
1172
1220
|
}
|
|
1173
1221
|
return blocker
|
|
1174
|
-
? `
|
|
1175
|
-
: "
|
|
1222
|
+
? `Keep these drafts review-only for now and start with: ${withTerminalPeriod(blocker)}`
|
|
1223
|
+
: "Keep these drafts review-only until the required setup and validation gaps are closed.";
|
|
1176
1224
|
}
|
|
1177
1225
|
function withTerminalPeriod(value) {
|
|
1178
1226
|
return /[.!?)]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`;
|
|
@@ -1354,6 +1402,10 @@ function inferFlowSuccessSignal(flow) {
|
|
|
1354
1402
|
if (/\bconfiguration verification\b/i.test(flow.title)) {
|
|
1355
1403
|
return "the affected build or runtime variant starts cleanly and handles fallback values";
|
|
1356
1404
|
}
|
|
1405
|
+
const visibleOutcome = flow.selectors.find((selector) => selector.kind === "visible-text" && isVisibleSuccessOutcome(selector.value));
|
|
1406
|
+
if (visibleOutcome) {
|
|
1407
|
+
return `visible text "${visibleOutcome.value}" appears`;
|
|
1408
|
+
}
|
|
1357
1409
|
const verificationStep = flow.steps.find((step) => isAssertionStep(step));
|
|
1358
1410
|
if (verificationStep) {
|
|
1359
1411
|
return stripTerminalPunctuation(verificationStep);
|
|
@@ -1364,32 +1416,36 @@ function inferFlowSuccessSignal(flow) {
|
|
|
1364
1416
|
}
|
|
1365
1417
|
return "the changed journey reaches a visible, stable success state";
|
|
1366
1418
|
}
|
|
1419
|
+
function isVisibleSuccessOutcome(value) {
|
|
1420
|
+
return /\b(?:confirmed|saved|refreshed|succeeded|success|completed|created|updated|deleted|sent|approved|accepted)\b/i.test(value) ||
|
|
1421
|
+
/(?:완료|성공|저장(?:됨|됐|되)|등록(?:됨|됐|되)|제출(?:됨|됐|되)|승인(?:됨|됐|되))/.test(value);
|
|
1422
|
+
}
|
|
1367
1423
|
function inferFlowReviewQuestion(flow, successSignal) {
|
|
1368
1424
|
if (isApiContractFocusedFlow(flow)) {
|
|
1369
|
-
return `Can a reviewer confirm that the changed endpoint, handler, or service contract is exercised and
|
|
1425
|
+
return `Can a reviewer confirm that the changed endpoint, handler, or service contract is exercised and this outcome is verified: ${successSignal}?`;
|
|
1370
1426
|
}
|
|
1371
1427
|
if (isDesignTokenFocusedFlow(flow)) {
|
|
1372
|
-
return `Can a reviewer confirm that the changed token artifact is regenerated, consumed, and
|
|
1428
|
+
return `Can a reviewer confirm that the changed token artifact is regenerated, consumed, and this outcome is verified: ${successSignal}?`;
|
|
1373
1429
|
}
|
|
1374
1430
|
if (isCatalogFocusedFlow(flow)) {
|
|
1375
|
-
return `Can a reviewer confirm that the changed catalog artifact is regenerated, consumed, and
|
|
1431
|
+
return `Can a reviewer confirm that the changed catalog artifact is regenerated, consumed, and this outcome is verified: ${successSignal}?`;
|
|
1376
1432
|
}
|
|
1377
1433
|
if (isTestEvidenceFocusedFlow(flow)) {
|
|
1378
|
-
return `Can a reviewer confirm that the changed tests are run and
|
|
1434
|
+
return `Can a reviewer confirm that the changed tests are run and this outcome is documented as PR evidence: ${successSignal}?`;
|
|
1379
1435
|
}
|
|
1380
1436
|
if (isDocumentationFocusedFlow(flow)) {
|
|
1381
|
-
return `Can a reviewer confirm that docs validation ran and
|
|
1437
|
+
return `Can a reviewer confirm that docs validation ran and this outcome is true: ${successSignal}?`;
|
|
1382
1438
|
}
|
|
1383
1439
|
if (isGeneratedArtifactFocusedFlow(flow)) {
|
|
1384
|
-
return `Can a reviewer confirm that the artifact was regenerated and
|
|
1440
|
+
return `Can a reviewer confirm that the artifact was regenerated and this outcome is true: ${successSignal}?`;
|
|
1385
1441
|
}
|
|
1386
1442
|
if (isCliCommandFocusedFlow(flow)) {
|
|
1387
|
-
return `Can a reviewer confirm that the changed command path is run and
|
|
1443
|
+
return `Can a reviewer confirm that the changed command path is run and this outcome is verified: ${successSignal}?`;
|
|
1388
1444
|
}
|
|
1389
1445
|
if (/\bconfiguration verification\b/i.test(flow.title)) {
|
|
1390
|
-
return `Can a reviewer confirm that the affected build, startup, or release variant is exercised and
|
|
1446
|
+
return `Can a reviewer confirm that the affected build, startup, or release variant is exercised and this outcome is verified: ${successSignal}?`;
|
|
1391
1447
|
}
|
|
1392
|
-
return `Can a reviewer confirm that ${flow.title} still works from this entrypoint and
|
|
1448
|
+
return `Can a reviewer confirm that ${flow.title} still works from this entrypoint and this outcome is verified: ${successSignal}?`;
|
|
1393
1449
|
}
|
|
1394
1450
|
function isApiContractFocusedFlow(flow) {
|
|
1395
1451
|
if (isEvidenceVerificationFocusedFlow(flow)) {
|
|
@@ -1786,7 +1842,7 @@ export function formatMarkdownE2eDraft(result) {
|
|
|
1786
1842
|
lines.push("");
|
|
1787
1843
|
lines.push("## Draft Readiness Summary");
|
|
1788
1844
|
lines.push("");
|
|
1789
|
-
lines.push(`
|
|
1845
|
+
lines.push(`Stage: ${formatDraftReadinessStage(result.readinessSummary)}. ${escapeMarkdownInline(result.readinessSummary.recommendation)}`);
|
|
1790
1846
|
lines.push(`Summary: ${result.actionSummary.required} required action${result.actionSummary.required === 1 ? "" : "s"}, ${result.actionSummary.recommended} recommended action${result.actionSummary.recommended === 1 ? "" : "s"}.`);
|
|
1791
1847
|
lines.push(`- Runnable candidates: ${result.readinessSummary.runnableCandidates}`);
|
|
1792
1848
|
lines.push(`- Near-runnable files: ${result.readinessSummary.nearRunnable}`);
|
|
@@ -1900,7 +1956,7 @@ export function formatMarkdownE2eSetup(result) {
|
|
|
1900
1956
|
lines.push(`- Output directory: \`${escapeMarkdownInline(result.draftOutputDirectory)}\``);
|
|
1901
1957
|
}
|
|
1902
1958
|
if (result.draftReadinessSummary) {
|
|
1903
|
-
lines.push(`-
|
|
1959
|
+
lines.push(`- Stage: ${formatDraftReadinessStage(result.draftReadinessSummary)}`);
|
|
1904
1960
|
}
|
|
1905
1961
|
for (const file of result.draftFiles) {
|
|
1906
1962
|
const details = [
|
|
@@ -2215,6 +2271,21 @@ function recommendRunner(project) {
|
|
|
2215
2271
|
reason: "No clear app platform was detected, so start with a manual smoke checklist before choosing a runnable E2E framework.",
|
|
2216
2272
|
};
|
|
2217
2273
|
}
|
|
2274
|
+
function recommendRunnerForChange(project, changedFiles) {
|
|
2275
|
+
if (isConfigurationOnlyChange(changedFiles)) {
|
|
2276
|
+
return {
|
|
2277
|
+
name: "manual",
|
|
2278
|
+
reason: "Use repository build and configuration validation because this diff changes configuration only; a browser or device journey would invent product coverage.",
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
if (isDocumentationOnlyChange(changedFiles) || isGeneratedOutputOnlyChange(changedFiles)) {
|
|
2282
|
+
return {
|
|
2283
|
+
name: "manual",
|
|
2284
|
+
reason: "Use repository documentation or artifact validation because this diff does not change a runtime product surface.",
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
return recommendRunner(project);
|
|
2288
|
+
}
|
|
2218
2289
|
function overrideRunner(project, runner) {
|
|
2219
2290
|
if (runner === "maestro") {
|
|
2220
2291
|
return {
|
|
@@ -3043,7 +3114,13 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
|
|
|
3043
3114
|
const behaviorFiles = files.filter((file) => !isTestLikeFile(file));
|
|
3044
3115
|
const candidateFiles = behaviorFiles.length > 0 ? behaviorFiles : files;
|
|
3045
3116
|
const impactSurfaceFiles = importImpacts.map((impact) => impact.surface).filter((surface) => !candidateFiles.includes(surface));
|
|
3046
|
-
|
|
3117
|
+
// Backend route modules often live under `routes/`, which is also a common
|
|
3118
|
+
// frontend surface directory. Once the repository is classified as an API
|
|
3119
|
+
// service, keep those files in contract analysis instead of inventing a UI
|
|
3120
|
+
// journey that the project cannot run.
|
|
3121
|
+
const uiFiles = projectType === "api-service"
|
|
3122
|
+
? []
|
|
3123
|
+
: uniqueStrings([...impactSurfaceFiles, ...candidateFiles.filter(isUserFacingFile)]);
|
|
3047
3124
|
const apiFiles = candidateFiles.filter(isApiLikeFile);
|
|
3048
3125
|
const apiServiceSourceFiles = projectType === "api-service"
|
|
3049
3126
|
? candidateFiles.filter((file) => !apiFiles.includes(file) && !isConfigLikeFile(file) && isServiceSourceFile(file))
|
|
@@ -3061,7 +3138,8 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
|
|
|
3061
3138
|
const domainFiles = candidateFiles.filter(isDomainOwnedFile);
|
|
3062
3139
|
const candidates = [];
|
|
3063
3140
|
if (uiFiles.length > 0) {
|
|
3064
|
-
const
|
|
3141
|
+
const subjectFiles = impactSurfaceFiles.length > 0 ? impactSurfaceFiles : uiFiles;
|
|
3142
|
+
const subject = summarizeFlowSubject(subjectFiles, "Changed", domainLanguage);
|
|
3065
3143
|
const impactReason = importImpacts.length > 0
|
|
3066
3144
|
? ` Changed shared files reach these surfaces through imports: ${importImpacts.slice(0, 3).map(describeImportChain).join("; ")}.`
|
|
3067
3145
|
: "";
|
|
@@ -3180,7 +3258,9 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
|
|
|
3180
3258
|
if (configFiles.length > 0) {
|
|
3181
3259
|
const subject = isReleaseMetadataOnlyChange(configFiles)
|
|
3182
3260
|
? "Release metadata"
|
|
3183
|
-
:
|
|
3261
|
+
: (projectType === "expo-react-native" || projectType === "react-native") && configFiles.some(isMobileNativeConfigFile)
|
|
3262
|
+
? "Mobile build"
|
|
3263
|
+
: summarizeFlowSubject(configFiles, "Changed", domainLanguage);
|
|
3184
3264
|
candidates.push({
|
|
3185
3265
|
kind: "config",
|
|
3186
3266
|
title: `${subject} configuration verification ${runner === "manual" ? "checklist" : "flow"}`,
|
|
@@ -3309,7 +3389,10 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
|
|
|
3309
3389
|
}
|
|
3310
3390
|
const coverage = buildCoverageTargets(candidate.kind, files, runner);
|
|
3311
3391
|
const setupHints = await inferFlowSetupHints(root, files, candidate.kind);
|
|
3312
|
-
const
|
|
3392
|
+
const interactionEvidenceApplies = !isVerificationOnlyKind(candidate.kind);
|
|
3393
|
+
const selectors = interactionEvidenceApplies
|
|
3394
|
+
? await inferFlowSelectors(root, files, runner, addedDiffText)
|
|
3395
|
+
: [];
|
|
3313
3396
|
const flow = {
|
|
3314
3397
|
title: candidate.title,
|
|
3315
3398
|
reason: candidate.reason,
|
|
@@ -3317,17 +3400,20 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
|
|
|
3317
3400
|
steps: refineStepsForInferredSelectors(candidate.steps, selectors),
|
|
3318
3401
|
coverage,
|
|
3319
3402
|
coverageEvidence: evaluateFlowCoverageEvidence({ title: candidate.title, files, coverage }, testSuiteInventory),
|
|
3320
|
-
entrypoints: await inferFlowEntrypoints(root, files, runner),
|
|
3403
|
+
entrypoints: interactionEvidenceApplies ? await inferFlowEntrypoints(root, files, runner) : [],
|
|
3321
3404
|
setupHints,
|
|
3322
3405
|
fixtureReadiness: await inferFlowFixtureReadiness(root, files, candidate.kind, setupHints, fixtureContext),
|
|
3323
3406
|
selectors,
|
|
3324
|
-
missingTestability: await findFlowTestabilityGaps(root, files, runner),
|
|
3407
|
+
missingTestability: interactionEvidenceApplies ? await findFlowTestabilityGaps(root, files, runner) : [],
|
|
3325
3408
|
};
|
|
3326
3409
|
return {
|
|
3327
3410
|
...flow,
|
|
3328
3411
|
languageBrief: buildFlowLanguageBrief(flow),
|
|
3329
3412
|
};
|
|
3330
3413
|
}
|
|
3414
|
+
function isVerificationOnlyKind(kind) {
|
|
3415
|
+
return kind === "config" || kind === "test-evidence" || kind === "documentation" || kind === "generated-artifact";
|
|
3416
|
+
}
|
|
3331
3417
|
function preferDiffAdded(selectors, predicate) {
|
|
3332
3418
|
return selectors.find((selector) => selector.addedInDiff && predicate(selector)) ?? selectors.find(predicate);
|
|
3333
3419
|
}
|
|
@@ -3381,7 +3467,11 @@ function selectorStepLabel(selector) {
|
|
|
3381
3467
|
return raw.length > 0 ? `"${raw}"` : `the ${selector.kind} control`;
|
|
3382
3468
|
}
|
|
3383
3469
|
function actionVerbForSelector(selector) {
|
|
3384
|
-
|
|
3470
|
+
const normalized = selector.value.replace(/[-_]+/g, " ");
|
|
3471
|
+
if (/\b(?:submit|send|apply|complete|confirm|save|continue|next|upload)\b/i.test(normalized)) {
|
|
3472
|
+
return "Submit";
|
|
3473
|
+
}
|
|
3474
|
+
if (/(?:제출|저장|보내기|전송|등록|신청|결제|구매|확인|완료)/.test(normalized)) {
|
|
3385
3475
|
return "Submit";
|
|
3386
3476
|
}
|
|
3387
3477
|
return "Activate";
|
|
@@ -3458,12 +3548,38 @@ function setupHint(kind, title, detail, files, confidence) {
|
|
|
3458
3548
|
confidence,
|
|
3459
3549
|
};
|
|
3460
3550
|
}
|
|
3551
|
+
const maxAnalyzedMockFiles = 24;
|
|
3461
3552
|
async function collectFixtureReadinessContext(root, changedFiles) {
|
|
3462
3553
|
const projectFiles = await collectProjectFiles(root, 12000);
|
|
3554
|
+
const changedMockFiles = changedFiles.filter(isMockOrFixtureFile).slice(0, maxFilesPerFlow);
|
|
3555
|
+
const projectMockEntries = projectFiles.filter((file) => isMockOrFixtureFile(file.path));
|
|
3556
|
+
// The project walk already loaded these files' text (with its own size and
|
|
3557
|
+
// text-extension guards); parsing it here is what lets fixture guidance
|
|
3558
|
+
// name concrete exports, handled routes, and keys.
|
|
3559
|
+
const walkTexts = new Map(projectFiles.map((file) => [file.path, file.text]));
|
|
3560
|
+
const mockFileInsights = new Map();
|
|
3561
|
+
for (const file of changedMockFiles) {
|
|
3562
|
+
let text = walkTexts.get(file);
|
|
3563
|
+
if (text === undefined && fixtureEvidenceSourceExtensions.has(path.extname(file).toLowerCase())) {
|
|
3564
|
+
text = await readTextIfExists(path.join(root, file));
|
|
3565
|
+
}
|
|
3566
|
+
if (text !== undefined) {
|
|
3567
|
+
mockFileInsights.set(file, analyzeFixtureSource(file, text));
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
for (const file of projectMockEntries) {
|
|
3571
|
+
if (mockFileInsights.size >= maxAnalyzedMockFiles) {
|
|
3572
|
+
break;
|
|
3573
|
+
}
|
|
3574
|
+
if (!mockFileInsights.has(file.path) && file.text !== undefined) {
|
|
3575
|
+
mockFileInsights.set(file.path, analyzeFixtureSource(file.path, file.text));
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3463
3578
|
return {
|
|
3464
3579
|
changedBackendFiles: changedFiles.filter(isBackendImplementationFile).slice(0, maxFilesPerFlow),
|
|
3465
|
-
changedMockFiles
|
|
3466
|
-
projectMockFiles:
|
|
3580
|
+
changedMockFiles,
|
|
3581
|
+
projectMockFiles: projectMockEntries.map((file) => file.path).slice(0, maxFilesPerFlow),
|
|
3582
|
+
mockFileInsights,
|
|
3467
3583
|
};
|
|
3468
3584
|
}
|
|
3469
3585
|
async function inferFlowFixtureReadiness(root, files, kind, setupHints, context) {
|
|
@@ -3535,6 +3651,7 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3535
3651
|
const fallbackProjectMockSignals = projectMockSignals.length > 0 ? projectMockSignals : context.projectMockFiles;
|
|
3536
3652
|
const mockSignals = uniqueStrings([...changedMockSignals, ...fallbackProjectMockSignals]).slice(0, maxFilesPerFlow);
|
|
3537
3653
|
if (changedMockSignals.length > 0) {
|
|
3654
|
+
const changedInsights = insightsForMockSignals(changedMockSignals, context);
|
|
3538
3655
|
return {
|
|
3539
3656
|
status: "ready",
|
|
3540
3657
|
reason: "This branch includes mock or fixture evidence for an API-dependent flow.",
|
|
@@ -3543,13 +3660,15 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3543
3660
|
backendSignals,
|
|
3544
3661
|
mockSignals,
|
|
3545
3662
|
nextActions: [
|
|
3546
|
-
`Keep changed fixture/mock evidence aligned with this flow: ${
|
|
3663
|
+
`Keep changed fixture/mock evidence aligned with this flow: ${describeFixtureEvidence(changedMockSignals, changedInsights)}.`,
|
|
3547
3664
|
"Keep deterministic success, empty, unauthorized, and failure fixture cases aligned with the changed flow.",
|
|
3548
3665
|
],
|
|
3666
|
+
...(changedInsights.length > 0 ? { mockInsights: changedInsights } : {}),
|
|
3549
3667
|
};
|
|
3550
3668
|
}
|
|
3551
3669
|
if (context.projectMockFiles.length > 0) {
|
|
3552
3670
|
const reusableMockSignals = mockSignals.length > 0 ? mockSignals : context.projectMockFiles;
|
|
3671
|
+
const reusableInsights = insightsForMockSignals(reusableMockSignals, context);
|
|
3553
3672
|
return {
|
|
3554
3673
|
status: "partial",
|
|
3555
3674
|
reason: "Mock or fixture infrastructure exists, but this branch does not add flow-specific fixture evidence.",
|
|
@@ -3558,9 +3677,10 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3558
3677
|
backendSignals,
|
|
3559
3678
|
mockSignals,
|
|
3560
3679
|
nextActions: [
|
|
3561
|
-
|
|
3680
|
+
reuseFixtureAction(reusableMockSignals, reusableInsights, apiEndpoints),
|
|
3562
3681
|
"Cover the primary success response and one empty, rejected, or server-error response.",
|
|
3563
3682
|
],
|
|
3683
|
+
...(reusableInsights.length > 0 ? { mockInsights: reusableInsights } : {}),
|
|
3564
3684
|
};
|
|
3565
3685
|
}
|
|
3566
3686
|
if (backendSignals.length > 0) {
|
|
@@ -3572,7 +3692,9 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3572
3692
|
backendSignals,
|
|
3573
3693
|
mockSignals,
|
|
3574
3694
|
nextActions: [
|
|
3575
|
-
|
|
3695
|
+
apiEndpoints.length > 0
|
|
3696
|
+
? `Confirm the test environment can serve ${formatEndpointSummary(apiEndpoints)}, or add a mock response for local E2E runs.`
|
|
3697
|
+
: "Confirm the test environment can serve the changed API path, or add a mock response for local E2E runs.",
|
|
3576
3698
|
"Seed realistic response data for success and failure paths.",
|
|
3577
3699
|
],
|
|
3578
3700
|
};
|
|
@@ -3585,11 +3707,59 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3585
3707
|
backendSignals,
|
|
3586
3708
|
mockSignals,
|
|
3587
3709
|
nextActions: [
|
|
3588
|
-
|
|
3710
|
+
apiEndpoints.length > 0
|
|
3711
|
+
? `Add a deterministic mock or fixture response for ${formatEndpointSummary(apiEndpoints)}, such as an MSW handler, Playwright route fulfillment, mock data, or seeded test data.`
|
|
3712
|
+
: "Add a deterministic mock or fixture response, such as MSW handlers, Playwright route fulfillment, mock data, or seeded test data.",
|
|
3589
3713
|
"Include success plus one empty, unauthorized, rejected, timeout, or server-error response.",
|
|
3590
3714
|
],
|
|
3591
3715
|
};
|
|
3592
3716
|
}
|
|
3717
|
+
function insightsForMockSignals(mockSignals, context) {
|
|
3718
|
+
return mockSignals
|
|
3719
|
+
.map((file) => context.mockFileInsights.get(file))
|
|
3720
|
+
.filter((insight) => insight !== undefined)
|
|
3721
|
+
.slice(0, 3);
|
|
3722
|
+
}
|
|
3723
|
+
function describeFixtureEvidence(mockSignals, insights) {
|
|
3724
|
+
if (insights.length === 0) {
|
|
3725
|
+
return formatFileSummary(mockSignals);
|
|
3726
|
+
}
|
|
3727
|
+
return insights.map(describeFixtureFile).join("; ");
|
|
3728
|
+
}
|
|
3729
|
+
function describeFixtureFile(insight) {
|
|
3730
|
+
if (insight.handledEndpoints.length > 0) {
|
|
3731
|
+
return `${insight.file} (already handles ${formatEndpointSummary(insight.handledEndpoints)})`;
|
|
3732
|
+
}
|
|
3733
|
+
if (insight.exports.length > 0) {
|
|
3734
|
+
return `${insight.file} (exports ${insight.exports.slice(0, 3).join(", ")})`;
|
|
3735
|
+
}
|
|
3736
|
+
return insight.file;
|
|
3737
|
+
}
|
|
3738
|
+
// Turns "reuse something somewhere" into "extend this file for these routes"
|
|
3739
|
+
// whenever the analyzed mock files give us that specificity.
|
|
3740
|
+
function reuseFixtureAction(reusableMockSignals, insights, apiEndpoints) {
|
|
3741
|
+
if (insights.length === 0 || apiEndpoints.length === 0) {
|
|
3742
|
+
return `Reuse or extend existing fixture/mock evidence for this flow: ${describeFixtureEvidence(reusableMockSignals, insights)}.`;
|
|
3743
|
+
}
|
|
3744
|
+
const uncovered = apiEndpoints.filter((endpoint) => !insights.some((insight) => insightCoversEndpoint(insight, endpoint)));
|
|
3745
|
+
if (uncovered.length === 0) {
|
|
3746
|
+
return `Wire the existing mock coverage into this flow's E2E run: ${describeFixtureEvidence(reusableMockSignals, insights)} for ${formatEndpointSummary(apiEndpoints)}.`;
|
|
3747
|
+
}
|
|
3748
|
+
const handlerInsight = insights.find((insight) => insight.handledEndpoints.length > 0);
|
|
3749
|
+
if (handlerInsight) {
|
|
3750
|
+
return `Extend ${describeFixtureFile(handlerInsight)} to also cover ${formatEndpointSummary(uncovered)}.`;
|
|
3751
|
+
}
|
|
3752
|
+
const exporterInsight = insights.find((insight) => insight.exports.length > 0);
|
|
3753
|
+
if (exporterInsight) {
|
|
3754
|
+
return `Reuse ${describeFixtureFile(exporterInsight)} to build a deterministic response for ${formatEndpointSummary(uncovered)}.`;
|
|
3755
|
+
}
|
|
3756
|
+
return `Reuse or extend existing fixture/mock evidence for ${formatEndpointSummary(uncovered)}: ${describeFixtureEvidence(reusableMockSignals, insights)}.`;
|
|
3757
|
+
}
|
|
3758
|
+
function formatEndpointSummary(endpoints) {
|
|
3759
|
+
const shown = endpoints.slice(0, 3);
|
|
3760
|
+
const remaining = endpoints.length - shown.length;
|
|
3761
|
+
return remaining > 0 ? `${shown.join(", ")} and ${remaining} more` : shown.join(", ");
|
|
3762
|
+
}
|
|
3593
3763
|
async function findApiDependencySignals(root, files, kind, setupHints) {
|
|
3594
3764
|
const signals = new Set();
|
|
3595
3765
|
if (kind === "api") {
|
|
@@ -3652,7 +3822,7 @@ function normalizeApiEndpointHint(value) {
|
|
|
3652
3822
|
if (!/(?:\/api(?:\/|$)|\/graphql(?:\/|$)|\/trpc(?:\/|$))/i.test(endpoint)) {
|
|
3653
3823
|
return undefined;
|
|
3654
3824
|
}
|
|
3655
|
-
if (/[\s<>{}]/.test(endpoint)) {
|
|
3825
|
+
if (/[\s<>{}`]/.test(endpoint)) {
|
|
3656
3826
|
return undefined;
|
|
3657
3827
|
}
|
|
3658
3828
|
return endpoint;
|
|
@@ -3697,10 +3867,40 @@ function isMockOrFixtureFile(file) {
|
|
|
3697
3867
|
const stem = basename.replace(/\.[^.]+$/g, "");
|
|
3698
3868
|
const extension = path.extname(basename).toLowerCase();
|
|
3699
3869
|
const canUseBroadFilenameMatch = fixtureEvidenceSourceExtensions.has(extension);
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3870
|
+
if (/(?:^|\/)(?:__mocks__|mocks?|fixtures?|factories|seeds?|test-data|testData|msw|mirage)\//i.test(file)) {
|
|
3871
|
+
return true;
|
|
3872
|
+
}
|
|
3873
|
+
// Match whole name tokens, not substrings: demoSeedService, APIMockClient,
|
|
3874
|
+
// and mock-users qualify, but a useSeedlingCatalog hook or an errorHandler
|
|
3875
|
+
// utility does not. "handler" alone is an ordinary code word, so it only
|
|
3876
|
+
// counts as mock evidence when the mock-ish directory rule above already
|
|
3877
|
+
// matched. Derived -ing forms stay excluded on purpose: the canonical
|
|
3878
|
+
// seed-data conventions (seeds/ and seeders/ directories, seed.* files)
|
|
3879
|
+
// are already covered by the rules above, and an -ing filename is
|
|
3880
|
+
// ambiguous between fixture data and application behavior — ambiguity
|
|
3881
|
+
// must not count as mock evidence.
|
|
3882
|
+
const stemTokens = stem
|
|
3883
|
+
.split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)
|
|
3884
|
+
.map((token) => token.toLowerCase());
|
|
3885
|
+
return canUseBroadFilenameMatch && stemTokens.some((token) => mockEvidenceNameTokens.has(token));
|
|
3886
|
+
}
|
|
3887
|
+
const mockEvidenceNameTokens = new Set([
|
|
3888
|
+
"mock",
|
|
3889
|
+
"mocks",
|
|
3890
|
+
"mocked",
|
|
3891
|
+
"mocking",
|
|
3892
|
+
"fixture",
|
|
3893
|
+
"fixtures",
|
|
3894
|
+
"factory",
|
|
3895
|
+
"factories",
|
|
3896
|
+
"seed",
|
|
3897
|
+
"seeds",
|
|
3898
|
+
"seeded",
|
|
3899
|
+
"seeder",
|
|
3900
|
+
"seeders",
|
|
3901
|
+
"msw",
|
|
3902
|
+
"mirage",
|
|
3903
|
+
]);
|
|
3704
3904
|
function isFixtureEvidenceIgnoredPath(file) {
|
|
3705
3905
|
return /(?:^|\/)(?:node_modules|vendor|vendors|Pods|build|dist|coverage|\.next|\.nuxt|\.expo|\.turbo|\.yarn\/cache)\//i.test(file);
|
|
3706
3906
|
}
|
|
@@ -4074,7 +4274,7 @@ async function buildSetupNotes(root, runner, project) {
|
|
|
4074
4274
|
return ["Choose an E2E runner after documenting the primary user-facing entry point for this project."];
|
|
4075
4275
|
}
|
|
4076
4276
|
function isUserFacingFile(file) {
|
|
4077
|
-
if (isApiRouteFile(file)) {
|
|
4277
|
+
if (isApiRouteFile(file) || isConfigLikeFile(file) || isTestLikeFile(file)) {
|
|
4078
4278
|
return false;
|
|
4079
4279
|
}
|
|
4080
4280
|
return (/(?:^|\/)(app|pages|routes|screens|components|ui|navigation)\//i.test(file) ||
|
|
@@ -4130,7 +4330,13 @@ function isCatalogDataFile(file) {
|
|
|
4130
4330
|
/(?:^|\/)(?:catalog|taxonomy)-site\/index\.html$/i.test(file);
|
|
4131
4331
|
}
|
|
4132
4332
|
function isConfigLikeFile(file) {
|
|
4133
|
-
return isReleaseMetadataFile(file) || /(?:(?:^|\/)(?:\.agents?|\.claude|\.cursor|\.dev|\.gemini|\.github|docs?)\/|(?:^|\/)(?:AGENTS|CLAUDE|CODEX|DECISIONS|GEMINI|PLAN|README|SKILL)\.md$|\.gitignore|package\.json|pnpm-lock\.yaml|yarn\.lock|package-lock\.json|bun\.lockb|pyproject\.toml|requirements\.txt|go\.mod|go\.sum|Cargo\.toml|Cargo\.lock|pom\.xml|build\.gradle|gradle\.properties|vite|webpack|babel|tsconfig|next\.config|app\.config|eas\.json|release-please|docker|env|feature-?flags?|experiments?)/i.test(file);
|
|
4333
|
+
return isReleaseMetadataFile(file) || isMobileNativeConfigFile(file) || /(?:(?:^|\/)(?:\.agents?|\.claude|\.cursor|\.dev|\.gemini|\.github|docs?)\/|(?:^|\/)(?:AGENTS|CLAUDE|CODEX|DECISIONS|GEMINI|PLAN|README|SKILL)\.md$|\.gitignore|package\.json|pnpm-lock\.yaml|yarn\.lock|package-lock\.json|bun\.lockb|pyproject\.toml|requirements\.txt|go\.mod|go\.sum|Cargo\.toml|Cargo\.lock|pom\.xml|build\.gradle|gradle\.properties|vite|webpack|babel|tsconfig|next\.config|app\.config|eas\.json|release-please|docker|env|feature-?flags?|experiments?)/i.test(file);
|
|
4334
|
+
}
|
|
4335
|
+
function isMobileNativeConfigFile(file) {
|
|
4336
|
+
return /(?:^|\/)(?:app\.json|eas\.json|Podfile(?:\.lock)?|Info\.plist|project\.pbxproj|AndroidManifest\.xml|[^/]+\.entitlements)$/i.test(file) ||
|
|
4337
|
+
/(?:^|\/)app\.config\.[cm]?[jt]s$/i.test(file) ||
|
|
4338
|
+
/(?:^|\/)android\/.+\.(?:gradle|properties)$/i.test(file) ||
|
|
4339
|
+
/(?:^|\/)ios\/[^/]+\.xcodeproj\/project\.pbxproj$/i.test(file);
|
|
4134
4340
|
}
|
|
4135
4341
|
function isReleaseMetadataFile(file) {
|
|
4136
4342
|
return /(?:^|\/)(?:CHANGELOG|RELEASES?|release-notes?|\.release-please-manifest)\.(?:md|json)$/i.test(file) ||
|
|
@@ -4163,7 +4369,11 @@ function isTestLikeFile(file) {
|
|
|
4163
4369
|
/(?:^|\/)test_[^/]+\.py$/i.test(file) ||
|
|
4164
4370
|
/(?:^|\/)[^/]+_test\.(?:py|go)$/i.test(file) ||
|
|
4165
4371
|
/(?:^|\/)[^/]+(?:Test|Tests|Spec)\.(?:java|kt|cs|swift)$/i.test(file) ||
|
|
4166
|
-
/(?:^|\/)[^/]+_(?:test|spec)\.rs$/i.test(file)
|
|
4372
|
+
/(?:^|\/)[^/]+_(?:test|spec)\.rs$/i.test(file) ||
|
|
4373
|
+
/(?:^|\/)\.maestro\/[^/]+\.ya?ml$/i.test(file));
|
|
4374
|
+
}
|
|
4375
|
+
function isConfigurationOnlyChange(files) {
|
|
4376
|
+
return files.length > 0 && files.every(isConfigLikeFile);
|
|
4167
4377
|
}
|
|
4168
4378
|
function isDocumentationFile(file) {
|
|
4169
4379
|
if (isReleaseMetadataFile(file)) {
|
|
@@ -4534,7 +4744,7 @@ function isImportantBaseDraftFlow(flow) {
|
|
|
4534
4744
|
}
|
|
4535
4745
|
function shouldUseDomainScenariosForDraft(plan) {
|
|
4536
4746
|
const files = plan.changedFiles.map((file) => file.path);
|
|
4537
|
-
if (isLowSignalVerificationOnlyChange(files)) {
|
|
4747
|
+
if (isLowSignalVerificationOnlyChange(files) || isConfigurationOnlyChange(files)) {
|
|
4538
4748
|
return false;
|
|
4539
4749
|
}
|
|
4540
4750
|
return !plan.flows.every(isEvidenceVerificationFocusedFlow);
|
|
@@ -4542,6 +4752,9 @@ function shouldUseDomainScenariosForDraft(plan) {
|
|
|
4542
4752
|
function isEvidenceVerificationFocusedFlow(flow) {
|
|
4543
4753
|
return isTestEvidenceFocusedFlow(flow) || isDocumentationFocusedFlow(flow) || isGeneratedArtifactFocusedFlow(flow);
|
|
4544
4754
|
}
|
|
4755
|
+
function isVerificationOnlyFlow(flow) {
|
|
4756
|
+
return isEvidenceVerificationFocusedFlow(flow) || /\bconfiguration verification\b/i.test(flow.title);
|
|
4757
|
+
}
|
|
4545
4758
|
function dedupeDraftFlowsByOutputPath(flows, runner) {
|
|
4546
4759
|
const flowsByOutput = new Map();
|
|
4547
4760
|
for (const flow of flows) {
|
|
@@ -4736,7 +4949,11 @@ async function buildDomainScenarioDraftFlow(plan, scenario, baseFlows) {
|
|
|
4736
4949
|
const reason = specializedScenario?.reason ?? scenario.intent;
|
|
4737
4950
|
const steps = specializedScenario?.steps ?? (scenario.checks.length > 0 ? scenario.checks : (baseFlow?.steps ?? []));
|
|
4738
4951
|
const scenarioFiles = normalizeScenarioFilesForRoot(plan, scenario.files);
|
|
4739
|
-
const files = uniqueStrings(
|
|
4952
|
+
const files = uniqueStrings(specializedScenario?.useBaseFlowFiles
|
|
4953
|
+
? [...(baseFlow?.files ?? []), ...scenarioFiles]
|
|
4954
|
+
: scenarioFiles.length > 0
|
|
4955
|
+
? scenarioFiles
|
|
4956
|
+
: (baseFlow?.files ?? [])).slice(0, 20);
|
|
4740
4957
|
const coverage = baseFlow?.coverage ?? buildCoverageTargets("domain", files, plan.recommendedRunner.name);
|
|
4741
4958
|
const runner = plan.recommendedRunner.name;
|
|
4742
4959
|
const entrypoints = uniqueEntrypoints([
|
|
@@ -4788,6 +5005,15 @@ function specializedDomainScenarioDraft(scenario, baseFlow) {
|
|
|
4788
5005
|
if (scenario.source !== "changed-file" || !baseFlow) {
|
|
4789
5006
|
return undefined;
|
|
4790
5007
|
}
|
|
5008
|
+
if (/through imports/i.test(baseFlow.reason) && /\sprimary journey$/i.test(scenario.title)) {
|
|
5009
|
+
const subject = baseFlow.title.replace(/\s+UI smoke flow$/i, "").trim();
|
|
5010
|
+
return {
|
|
5011
|
+
title: subject,
|
|
5012
|
+
reason: baseFlow.reason,
|
|
5013
|
+
steps: baseFlow.steps,
|
|
5014
|
+
useBaseFlowFiles: true,
|
|
5015
|
+
};
|
|
5016
|
+
}
|
|
4791
5017
|
if (isApiContractFocusedFlow(baseFlow)) {
|
|
4792
5018
|
const subject = scenario.title.replace(/\s+primary journey$/i, "");
|
|
4793
5019
|
return {
|
|
@@ -4939,7 +5165,7 @@ function domainScenarioForFlow(flow) {
|
|
|
4939
5165
|
return flow.domainScenario;
|
|
4940
5166
|
}
|
|
4941
5167
|
function draftStability(plan, flow) {
|
|
4942
|
-
const needsSelector = flow.missingTestability.length > 0;
|
|
5168
|
+
const needsSelector = !isVerificationOnlyFlow(flow) && flow.missingTestability.length > 0;
|
|
4943
5169
|
const needsSetup = plan.missingTestability.some((gap) => /No \.maestro|No Playwright config/i.test(gap));
|
|
4944
5170
|
if (needsSelector && needsSetup) {
|
|
4945
5171
|
return "needs-selector-and-setup";
|
|
@@ -4953,14 +5179,15 @@ function draftStability(plan, flow) {
|
|
|
4953
5179
|
return "ready";
|
|
4954
5180
|
}
|
|
4955
5181
|
function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck) {
|
|
4956
|
-
const
|
|
4957
|
-
|
|
5182
|
+
const verificationOnly = isVerificationOnlyFlow(flow);
|
|
5183
|
+
const blockers = verificationOnly ? [] : [...plan.executionProfile.blockers];
|
|
5184
|
+
if (!verificationOnly && runner !== "manual" && flow.entrypoints.length === 0) {
|
|
4958
5185
|
blockers.push("No runnable route, screen, or command entrypoint was inferred for this flow.");
|
|
4959
5186
|
}
|
|
4960
|
-
if (flow.missingTestability.length > 0) {
|
|
5187
|
+
if (!verificationOnly && flow.missingTestability.length > 0) {
|
|
4961
5188
|
blockers.push(flow.missingTestability[0]);
|
|
4962
5189
|
}
|
|
4963
|
-
if (flow.fixtureReadiness.status === "missing") {
|
|
5190
|
+
if (!verificationOnly && flow.fixtureReadiness.status === "missing") {
|
|
4964
5191
|
blockers.push(flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason);
|
|
4965
5192
|
}
|
|
4966
5193
|
if (validationSummary.blockingGapCount > 0) {
|
|
@@ -4972,7 +5199,7 @@ function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck
|
|
|
4972
5199
|
return uniqueStrings(blockers).slice(0, 8);
|
|
4973
5200
|
}
|
|
4974
5201
|
function draftRunnableStatus(plan, flow, runner, validationSummary, executionBlockers, selfCheck) {
|
|
4975
|
-
if (runner === "manual") {
|
|
5202
|
+
if (runner === "manual" || isVerificationOnlyFlow(flow)) {
|
|
4976
5203
|
return "review-only";
|
|
4977
5204
|
}
|
|
4978
5205
|
if (selfCheck?.status === "fail") {
|
|
@@ -5212,12 +5439,12 @@ function draftExtension(runner) {
|
|
|
5212
5439
|
}
|
|
5213
5440
|
return ".md";
|
|
5214
5441
|
}
|
|
5215
|
-
function draftContentForFlow(plan, flow, runner) {
|
|
5442
|
+
function draftContentForFlow(plan, flow, runner, addedDiffText = {}) {
|
|
5216
5443
|
if (runner === "maestro") {
|
|
5217
5444
|
return buildMaestroDraft(plan, flow);
|
|
5218
5445
|
}
|
|
5219
5446
|
if (runner === "playwright") {
|
|
5220
|
-
return buildPlaywrightDraft(plan, flow);
|
|
5447
|
+
return buildPlaywrightDraft(plan, flow, addedDiffText);
|
|
5221
5448
|
}
|
|
5222
5449
|
return buildManualDraft(plan, flow);
|
|
5223
5450
|
}
|
|
@@ -5310,7 +5537,7 @@ function maestroCommandForStep(step, selectors) {
|
|
|
5310
5537
|
value: maestroSelectorValue(selector),
|
|
5311
5538
|
};
|
|
5312
5539
|
}
|
|
5313
|
-
function buildPlaywrightDraft(plan, flow) {
|
|
5540
|
+
function buildPlaywrightDraft(plan, flow, addedDiffText = {}) {
|
|
5314
5541
|
const testName = flow.title.replaceAll('"', "'");
|
|
5315
5542
|
const selectorQueue = [...flow.selectors];
|
|
5316
5543
|
const scenario = domainScenarioForFlow(flow);
|
|
@@ -5367,6 +5594,7 @@ function buildPlaywrightDraft(plan, flow) {
|
|
|
5367
5594
|
: playwrightFallbackActionForStep(step));
|
|
5368
5595
|
appendPlaywrightTestStep(lines, step, body);
|
|
5369
5596
|
}
|
|
5597
|
+
appendObservedResponseAssertion(lines, flow, addedDiffText);
|
|
5370
5598
|
appendDomainScenarioComments(lines, flow, " //");
|
|
5371
5599
|
appendPlaywrightCoverageComments(lines, flow);
|
|
5372
5600
|
lines.push("});");
|
|
@@ -5964,12 +6192,7 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
|
|
|
5964
6192
|
if (flow.fixtureReadiness.status === "not-needed" || flow.fixtureReadiness.apiEndpoints.length === 0) {
|
|
5965
6193
|
return;
|
|
5966
6194
|
}
|
|
5967
|
-
const
|
|
5968
|
-
const observedEndpoints = changedEndpointHints.length > 0
|
|
5969
|
-
? changedEndpointHints
|
|
5970
|
-
: flow.fixtureReadiness.backendSignals.length > 0
|
|
5971
|
-
? flow.fixtureReadiness.apiEndpoints
|
|
5972
|
-
: [];
|
|
6195
|
+
const observedEndpoints = observedEndpointsForFlow(flow);
|
|
5973
6196
|
if (observedEndpoints.length > 0) {
|
|
5974
6197
|
appendPlaywrightApiObservationScaffold(lines, observedEndpoints);
|
|
5975
6198
|
}
|
|
@@ -5979,19 +6202,39 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
|
|
|
5979
6202
|
if (mockableEndpoints.length === 0) {
|
|
5980
6203
|
return;
|
|
5981
6204
|
}
|
|
6205
|
+
const insights = flow.fixtureReadiness.mockInsights ?? [];
|
|
6206
|
+
const shapeSources = [];
|
|
5982
6207
|
lines.push("");
|
|
5983
6208
|
lines.push(" const mockApiResponses = {");
|
|
5984
6209
|
for (const endpoint of mockableEndpoints.slice(0, maxFilesPerFlow)) {
|
|
6210
|
+
const insight = insights.find((candidate) => specSafeSampleKeys(candidate).length > 0 && insightCoversEndpoint(candidate, endpoint)) ??
|
|
6211
|
+
insights.find((candidate) => specSafeSampleKeys(candidate).length > 0);
|
|
5985
6212
|
lines.push(` "${quoteJs(playwrightMockRoutePattern(endpoint))}": {`);
|
|
5986
6213
|
lines.push(" status: 200,");
|
|
5987
6214
|
lines.push(" body: {");
|
|
5988
|
-
|
|
5989
|
-
|
|
6215
|
+
if (insight) {
|
|
6216
|
+
if (!shapeSources.includes(insight.file)) {
|
|
6217
|
+
shapeSources.push(insight.file);
|
|
6218
|
+
}
|
|
6219
|
+
for (const key of specSafeSampleKeys(insight)) {
|
|
6220
|
+
const propertyName = isJsIdentifier(key) ? key : JSON.stringify(key);
|
|
6221
|
+
lines.push(` ${propertyName}: ${JSON.stringify(`qamap-${key}`)},`);
|
|
6222
|
+
}
|
|
6223
|
+
}
|
|
6224
|
+
else {
|
|
6225
|
+
lines.push(' ok: true,');
|
|
6226
|
+
lines.push(' source: "qamap-draft",');
|
|
6227
|
+
}
|
|
5990
6228
|
lines.push(" },");
|
|
5991
6229
|
lines.push(" },");
|
|
5992
6230
|
}
|
|
5993
6231
|
lines.push(" };");
|
|
5994
|
-
|
|
6232
|
+
if (shapeSources.length > 0) {
|
|
6233
|
+
lines.push(` // Response shape keys reuse ${formatFileSummary(shapeSources)}; replace the sample values with deterministic fixture data before promoting this draft.`);
|
|
6234
|
+
}
|
|
6235
|
+
else {
|
|
6236
|
+
lines.push(" // Replace sample responses with deterministic fixtures from the target domain before promoting this draft.");
|
|
6237
|
+
}
|
|
5995
6238
|
lines.push(" for (const [urlPattern, response] of Object.entries(mockApiResponses)) {");
|
|
5996
6239
|
lines.push(" await page.route(urlPattern, async (route) => {");
|
|
5997
6240
|
lines.push(" await route.fulfill({");
|
|
@@ -6002,6 +6245,71 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
|
|
|
6002
6245
|
lines.push(" });");
|
|
6003
6246
|
lines.push(" }");
|
|
6004
6247
|
}
|
|
6248
|
+
// Drafts are pinned to contain no literal TODO marker, so a fixture key that
|
|
6249
|
+
// would introduce one disqualifies itself from spec interpolation.
|
|
6250
|
+
function specSafeSampleKeys(insight) {
|
|
6251
|
+
return insight.sampleKeys.filter((key) => !/todo/i.test(key));
|
|
6252
|
+
}
|
|
6253
|
+
function observedEndpointsForFlow(flow) {
|
|
6254
|
+
if (flow.fixtureReadiness.status === "not-needed" || flow.fixtureReadiness.apiEndpoints.length === 0) {
|
|
6255
|
+
return [];
|
|
6256
|
+
}
|
|
6257
|
+
const changedEndpointHints = uniqueStrings(flow.fixtureReadiness.backendSignals.flatMap(apiEndpointFromBackendFile));
|
|
6258
|
+
if (changedEndpointHints.length > 0) {
|
|
6259
|
+
return changedEndpointHints;
|
|
6260
|
+
}
|
|
6261
|
+
return flow.fixtureReadiness.backendSignals.length > 0 ? flow.fixtureReadiness.apiEndpoints : [];
|
|
6262
|
+
}
|
|
6263
|
+
function collectChangedHandlerEvidence(flow, addedDiffText) {
|
|
6264
|
+
const statuses = [];
|
|
6265
|
+
const responseKeys = [];
|
|
6266
|
+
for (const file of flow.fixtureReadiness.backendSignals) {
|
|
6267
|
+
const addedText = addedDiffText[file];
|
|
6268
|
+
if (!addedText) {
|
|
6269
|
+
continue;
|
|
6270
|
+
}
|
|
6271
|
+
for (const match of addedText.matchAll(/(?:status[:(]\s*|\.status\(\s*)(\d{3})\b/g)) {
|
|
6272
|
+
const status = Number(match[1]);
|
|
6273
|
+
if (status >= 100 && status < 600) {
|
|
6274
|
+
statuses.push(status);
|
|
6275
|
+
}
|
|
6276
|
+
}
|
|
6277
|
+
for (const match of addedText.matchAll(/(?:NextResponse|Response|res)\.json\(\s*\{([^}]{1,200})\}/g)) {
|
|
6278
|
+
for (const keyMatch of match[1].matchAll(/(?:^|[,{])\s*([A-Za-z_$][\w$]*)\s*[:,}]/g)) {
|
|
6279
|
+
responseKeys.push(keyMatch[1]);
|
|
6280
|
+
}
|
|
6281
|
+
}
|
|
6282
|
+
}
|
|
6283
|
+
const uniqueStatuses = [...new Set(statuses)];
|
|
6284
|
+
return {
|
|
6285
|
+
statuses: uniqueStatuses,
|
|
6286
|
+
successOnly: uniqueStatuses.length > 0 && uniqueStatuses.every((status) => status < 400),
|
|
6287
|
+
responseKeys: [...new Set(responseKeys)].slice(0, 6),
|
|
6288
|
+
};
|
|
6289
|
+
}
|
|
6290
|
+
function appendObservedResponseAssertion(lines, flow, addedDiffText) {
|
|
6291
|
+
if (observedEndpointsForFlow(flow).length === 0) {
|
|
6292
|
+
return;
|
|
6293
|
+
}
|
|
6294
|
+
const evidence = collectChangedHandlerEvidence(flow, addedDiffText);
|
|
6295
|
+
const statusCeiling = evidence.successOnly ? 400 : 500;
|
|
6296
|
+
lines.push("");
|
|
6297
|
+
if (evidence.successOnly) {
|
|
6298
|
+
lines.push(` // The added handler code only shows success statuses (${evidence.statuses.join(", ")}), so any 4xx/5xx here is unexpected.`);
|
|
6299
|
+
}
|
|
6300
|
+
else {
|
|
6301
|
+
lines.push(" // Changed-endpoint check: the journey must not surface server errors from the code under test.");
|
|
6302
|
+
}
|
|
6303
|
+
if (evidence.responseKeys.length > 0) {
|
|
6304
|
+
lines.push(` // Response shape hint from the changed handler: { ${evidence.responseKeys.join(", ")} } — assert on these fields when promoting this draft.`);
|
|
6305
|
+
}
|
|
6306
|
+
lines.push(" for (const response of observedChangedApiResponses) {");
|
|
6307
|
+
lines.push(` expect(response.status, \`unexpected status from \${response.url}\`).toBeLessThan(${statusCeiling});`);
|
|
6308
|
+
lines.push(" }");
|
|
6309
|
+
lines.push(" if (observedChangedApiResponses.length === 0) {");
|
|
6310
|
+
lines.push(' console.warn("Changed endpoints were not exercised by this draft; extend the steps above to cover them.");');
|
|
6311
|
+
lines.push(" }");
|
|
6312
|
+
}
|
|
6005
6313
|
function appendPlaywrightApiObservationScaffold(lines, endpoints) {
|
|
6006
6314
|
lines.push("");
|
|
6007
6315
|
lines.push(" const changedApiEndpointPatterns = [");
|
|
@@ -6703,7 +7011,7 @@ function withoutSelectorValueDuplicates(selectors, existing) {
|
|
|
6703
7011
|
function extractAttributeSelectors(file, text, attributes, kind) {
|
|
6704
7012
|
const selectors = [];
|
|
6705
7013
|
for (const attribute of attributes) {
|
|
6706
|
-
const matcher = new RegExp(
|
|
7014
|
+
const matcher = new RegExp(`(?<![:@.\\w-])${escapeRegExp(attribute)}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{\\s*["'\`]([^"'\`{}]+)["'\`]\\s*\\})`, "g");
|
|
6707
7015
|
for (const match of text.matchAll(matcher)) {
|
|
6708
7016
|
const value = normalizeSelectorValue(match[1] ?? match[2] ?? match[3]);
|
|
6709
7017
|
if (value) {
|
|
@@ -6732,6 +7040,28 @@ function extractTextNodeSelectors(file, text) {
|
|
|
6732
7040
|
for (const selector of extractAttributeSelectors(file, text, ["title"], "visible-text")) {
|
|
6733
7041
|
selectors.push(selector);
|
|
6734
7042
|
}
|
|
7043
|
+
selectors.push(...extractRenderedStateSelectors(file, text));
|
|
7044
|
+
return selectors;
|
|
7045
|
+
}
|
|
7046
|
+
function extractRenderedStateSelectors(file, text) {
|
|
7047
|
+
const selectors = [];
|
|
7048
|
+
const renderedNames = new Set([...text.matchAll(/>\s*\{\s*([A-Za-z_$][\w$]*)\s*\}\s*</g)].map((match) => match[1]));
|
|
7049
|
+
for (const stateName of renderedNames) {
|
|
7050
|
+
const stateDeclaration = new RegExp(`\\bconst\\s*\\[\\s*${escapeRegExp(stateName)}\\s*,\\s*([A-Za-z_$][\\w$]*)\\s*\\]\\s*=\\s*useState\\b`).exec(text);
|
|
7051
|
+
const setterName = stateDeclaration?.[1];
|
|
7052
|
+
if (!setterName) {
|
|
7053
|
+
continue;
|
|
7054
|
+
}
|
|
7055
|
+
const setterCallMatcher = new RegExp(`\\b${escapeRegExp(setterName)}\\s*\\(([^\\n;]{0,300})\\)`, "g");
|
|
7056
|
+
for (const call of text.matchAll(setterCallMatcher)) {
|
|
7057
|
+
for (const literal of call[1].matchAll(/"([^"]+)"|'([^']+)'|`([^`]+)`/g)) {
|
|
7058
|
+
const value = normalizeSelectorValue(literal[1] ?? literal[2] ?? literal[3]);
|
|
7059
|
+
if (value && isUsefulSelector(value)) {
|
|
7060
|
+
selectors.push({ kind: "visible-text", value, file });
|
|
7061
|
+
}
|
|
7062
|
+
}
|
|
7063
|
+
}
|
|
7064
|
+
}
|
|
6735
7065
|
return selectors;
|
|
6736
7066
|
}
|
|
6737
7067
|
function extractRoleSelectorsFromText(file, text) {
|
|
@@ -6761,7 +7091,15 @@ function normalizeSelectorValue(value) {
|
|
|
6761
7091
|
return normalized || undefined;
|
|
6762
7092
|
}
|
|
6763
7093
|
function isUsefulSelector(value) {
|
|
6764
|
-
|
|
7094
|
+
if (value.length < 2 || value.length > 80 || /[{}()[\]=>]/.test(value)) {
|
|
7095
|
+
return false;
|
|
7096
|
+
}
|
|
7097
|
+
// Dotted tokens without spaces are almost always i18n keys or property paths,
|
|
7098
|
+
// never rendered UI text, so a locator built from them can never match.
|
|
7099
|
+
if (/^[\w$-]+(?:\.[\w$-]+)+$/.test(value)) {
|
|
7100
|
+
return false;
|
|
7101
|
+
}
|
|
7102
|
+
return true;
|
|
6765
7103
|
}
|
|
6766
7104
|
async function readPackageJson(root) {
|
|
6767
7105
|
try {
|
|
@@ -6981,11 +7319,12 @@ function selectorRank(kind) {
|
|
|
6981
7319
|
return 4;
|
|
6982
7320
|
}
|
|
6983
7321
|
function slugify(value) {
|
|
6984
|
-
|
|
7322
|
+
const slug = value
|
|
6985
7323
|
.toLowerCase()
|
|
6986
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
7324
|
+
.replace(/[^a-z0-9\uAC00-\uD7A3]+/g, "-")
|
|
6987
7325
|
.replace(/^-+|-+$/g, "")
|
|
6988
7326
|
.slice(0, 80);
|
|
7327
|
+
return slug || "draft";
|
|
6989
7328
|
}
|
|
6990
7329
|
function quoteYaml(value) {
|
|
6991
7330
|
return JSON.stringify(value);
|