@ivorycanvas/qamap 0.3.3 → 0.3.5
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 +54 -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 +48 -5
- package/dist/domain-language.js.map +1 -1
- package/dist/e2e.d.ts +4 -0
- package/dist/e2e.js +385 -74
- 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 +615 -57
- package/dist/manifest.js.map +1 -1
- package/dist/qa.d.ts +4 -0
- package/dist/qa.js +125 -18
- 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 +65 -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 +7 -3
- package/docs/quickstart-demo.md +1 -1
- package/docs/release-validation.md +35 -34
- 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 +1 -0
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;
|
|
@@ -459,6 +476,7 @@ function buildE2eValidationMatrix(flows, coreFlows) {
|
|
|
459
476
|
}
|
|
460
477
|
function buildE2eBootstrapPlan(input) {
|
|
461
478
|
const steps = [];
|
|
479
|
+
const verificationOnly = input.flows.length > 0 && input.flows.every(isVerificationOnlyFlow);
|
|
462
480
|
const runnerGap = input.missingTestability.find((gap) => /No \.maestro|No Playwright config/i.test(gap));
|
|
463
481
|
const draftCommand = `qamap e2e draft . --base ${input.base} --head ${input.head}`;
|
|
464
482
|
const planHistoryCommand = `qamap e2e plan . --base ${input.base} --head ${input.head} --record-history`;
|
|
@@ -471,7 +489,10 @@ function buildE2eBootstrapPlan(input) {
|
|
|
471
489
|
? `${concreteTargets.length} changed workspace target${concreteTargets.length === 1 ? "" : "s"} have clearer app or service signals than the workspace root.`
|
|
472
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)));
|
|
473
491
|
}
|
|
474
|
-
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") {
|
|
475
496
|
steps.push(bootstrapStep("runner", "required", manualBootstrapTitle(input.projectType), manualBootstrapReason(input.projectType), manualBootstrapAction(input.projectType), [], []));
|
|
476
497
|
}
|
|
477
498
|
else if (runnerGap) {
|
|
@@ -487,27 +508,30 @@ function buildE2eBootstrapPlan(input) {
|
|
|
487
508
|
else {
|
|
488
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.", [], []));
|
|
489
510
|
}
|
|
490
|
-
if (input.recommendedRunner.name !== "manual" && executionProfileBlockers.length > 0) {
|
|
511
|
+
if (!verificationOnly && input.recommendedRunner.name !== "manual" && executionProfileBlockers.length > 0) {
|
|
491
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));
|
|
492
513
|
}
|
|
493
|
-
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) {
|
|
494
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)));
|
|
495
519
|
}
|
|
496
520
|
else {
|
|
497
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.", [], []));
|
|
498
522
|
}
|
|
499
|
-
if (!input.domainManifestPath && input.domainLanguage.terms.length > 0) {
|
|
523
|
+
if (!verificationOnly && !input.domainManifestPath && input.domainLanguage.terms.length > 0) {
|
|
500
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)));
|
|
501
525
|
}
|
|
502
|
-
else if (input.domainManifestPath || input.domains.length > 0) {
|
|
526
|
+
else if (!verificationOnly && (input.domainManifestPath || input.domains.length > 0)) {
|
|
503
527
|
steps.push(bootstrapStep("domain-language", "ready", "Domain language has shared policy evidence", input.domainManifestPath
|
|
504
528
|
? `Domain manifest found at ${input.domainManifestPath}.`
|
|
505
529
|
: "Matched domain definitions were found.", "Keep committed domain language aligned with the names used in generated E2E drafts.", [], []));
|
|
506
530
|
}
|
|
507
|
-
if (!input.coreFlowManifestPath) {
|
|
531
|
+
if (!verificationOnly && !input.coreFlowManifestPath) {
|
|
508
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)));
|
|
509
533
|
}
|
|
510
|
-
else {
|
|
534
|
+
else if (!verificationOnly) {
|
|
511
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
|
|
512
536
|
? `${input.coreFlows.length} declared core flow${input.coreFlows.length === 1 ? "" : "s"} matched the changed files.`
|
|
513
537
|
: `Core flow manifest found at ${input.coreFlowManifestPath}, but this change did not match a declared flow.`, input.coreFlows.length > 0
|
|
@@ -527,24 +551,30 @@ function buildE2eBootstrapPlan(input) {
|
|
|
527
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)));
|
|
528
552
|
}
|
|
529
553
|
const nonRunnerTestabilityGaps = input.missingTestability.filter((gap) => !/No \.maestro|No Playwright config/i.test(gap));
|
|
530
|
-
if (nonRunnerTestabilityGaps.length > 0) {
|
|
554
|
+
if (!verificationOnly && nonRunnerTestabilityGaps.length > 0) {
|
|
531
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)));
|
|
532
556
|
}
|
|
533
|
-
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)) {
|
|
534
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)));
|
|
535
559
|
}
|
|
536
560
|
if (input.validationMatrix.summary.missing > 0) {
|
|
537
561
|
steps.push(bootstrapStep("validation", "required", "Close missing validation matrix rows", input.validationMatrix.summary.missing === 1
|
|
538
562
|
? "1 validation row is missing evidence."
|
|
539
|
-
: `${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)));
|
|
540
566
|
}
|
|
541
567
|
else if (input.validationMatrix.summary.partial > 0) {
|
|
542
568
|
steps.push(bootstrapStep("validation", "recommended", "Strengthen partial validation evidence", input.validationMatrix.summary.partial === 1
|
|
543
569
|
? "1 validation row is only partial."
|
|
544
|
-
: `${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)));
|
|
545
573
|
}
|
|
546
574
|
else if (input.validationMatrix.rows.length > 0) {
|
|
547
|
-
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.", [], []));
|
|
548
578
|
}
|
|
549
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], []));
|
|
550
580
|
if (input.suggestedCommands.length === 0) {
|
|
@@ -557,7 +587,7 @@ function buildE2eBootstrapPlan(input) {
|
|
|
557
587
|
ready: sortedSteps.filter((step) => step.status === "ready").length,
|
|
558
588
|
};
|
|
559
589
|
return {
|
|
560
|
-
summary: bootstrapSummary(counts, sortedSteps),
|
|
590
|
+
summary: bootstrapSummary(counts, sortedSteps, verificationOnly),
|
|
561
591
|
steps: sortedSteps,
|
|
562
592
|
counts,
|
|
563
593
|
};
|
|
@@ -661,9 +691,12 @@ function bootstrapCategoryRank(category) {
|
|
|
661
691
|
];
|
|
662
692
|
return order.indexOf(category);
|
|
663
693
|
}
|
|
664
|
-
function bootstrapSummary(counts, steps) {
|
|
694
|
+
function bootstrapSummary(counts, steps, verificationOnly = false) {
|
|
665
695
|
const firstRequired = steps.find((step) => step.status === "required");
|
|
666
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
|
+
}
|
|
667
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}.`;
|
|
668
701
|
}
|
|
669
702
|
const firstRecommended = steps.find((step) => step.status === "recommended");
|
|
@@ -922,6 +955,7 @@ function buildDraftPromotionGuidance(flow) {
|
|
|
922
955
|
}
|
|
923
956
|
function buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck) {
|
|
924
957
|
const items = [];
|
|
958
|
+
const verificationOnly = isVerificationOnlyFlow(flow);
|
|
925
959
|
const runnerGap = runnerSetupGap(plan, runner);
|
|
926
960
|
if (runnerGap) {
|
|
927
961
|
const setupCommand = plan.runnerSetup.setupCommand ? ` Run \`${plan.runnerSetup.setupCommand}\` after accepting this runner setup.` : "";
|
|
@@ -931,7 +965,7 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
|
|
|
931
965
|
items.push(draftActionItem("runner", "required", `Configure ${formatRunnerName(runner)} execution`, setupDetail));
|
|
932
966
|
}
|
|
933
967
|
const executionBlockers = remainingExecutionProfileBlockers(plan.executionProfile.blockers, runnerGap);
|
|
934
|
-
if (runner !== "manual" && executionBlockers.length > 0) {
|
|
968
|
+
if (!verificationOnly && runner !== "manual" && executionBlockers.length > 0) {
|
|
935
969
|
items.push(draftActionItem("runner", "required", "Complete execution profile", executionBlockers.slice(0, 3).join(" ")));
|
|
936
970
|
}
|
|
937
971
|
const route = primaryRouteEntrypoint(flow);
|
|
@@ -942,34 +976,42 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
|
|
|
942
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}.`));
|
|
943
977
|
}
|
|
944
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
|
+
: "";
|
|
945
983
|
if (flow.fixtureReadiness.status === "missing") {
|
|
946
|
-
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));
|
|
947
985
|
}
|
|
948
986
|
else if (flow.fixtureReadiness.status === "partial") {
|
|
949
|
-
items.push(draftActionItem("fixture", "recommended",
|
|
987
|
+
items.push(draftActionItem("fixture", "recommended", `Confirm fixture coverage${fixtureEndpointSuffix}`, flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason));
|
|
950
988
|
}
|
|
951
|
-
if (flow.missingTestability.length > 0) {
|
|
989
|
+
if (!verificationOnly && flow.missingTestability.length > 0) {
|
|
952
990
|
items.push(draftActionItem("selector", "required", "Replace weak or missing selectors", flow.missingTestability[0]));
|
|
953
991
|
}
|
|
954
|
-
else if (flow.selectors.length === 0 && runner !== "manual") {
|
|
992
|
+
else if (!verificationOnly && flow.selectors.length === 0 && runner !== "manual") {
|
|
955
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."));
|
|
956
994
|
}
|
|
957
|
-
if (flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
|
|
995
|
+
if (!verificationOnly && flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
|
|
958
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.`));
|
|
959
997
|
}
|
|
960
|
-
if (selfCheck?.status === "fail") {
|
|
998
|
+
if (!verificationOnly && selfCheck?.status === "fail") {
|
|
961
999
|
items.push(draftActionItem("validation", "required", "Resolve generated draft self-check", selfCheck.blockers[0] ?? selfCheck.summary));
|
|
962
1000
|
}
|
|
963
|
-
else if (selfCheck?.status === "warning") {
|
|
1001
|
+
else if (!verificationOnly && selfCheck?.status === "warning") {
|
|
964
1002
|
items.push(draftActionItem("validation", "recommended", "Review generated draft self-check", selfCheck.summary));
|
|
965
1003
|
}
|
|
966
1004
|
if (validationSummary.blockingGapCount > 0) {
|
|
967
|
-
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.`));
|
|
968
1008
|
}
|
|
969
1009
|
else if (validationSummary.gapCount > 0) {
|
|
970
|
-
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.`));
|
|
971
1013
|
}
|
|
972
|
-
if (promotionGuidance.status !== "commit-candidate") {
|
|
1014
|
+
if (!verificationOnly && promotionGuidance.status !== "commit-candidate") {
|
|
973
1015
|
items.push(draftActionItem("manifest", "recommended", "Promote durable product language", promotionGuidance.action));
|
|
974
1016
|
}
|
|
975
1017
|
return uniqueDraftActionItems(items).slice(0, 8);
|
|
@@ -1177,8 +1219,8 @@ function draftReadinessRecommendation(level, blockers) {
|
|
|
1177
1219
|
: "Close required action items before treating the drafts as regression evidence.";
|
|
1178
1220
|
}
|
|
1179
1221
|
return blocker
|
|
1180
|
-
? `
|
|
1181
|
-
: "
|
|
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.";
|
|
1182
1224
|
}
|
|
1183
1225
|
function withTerminalPeriod(value) {
|
|
1184
1226
|
return /[.!?)]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`;
|
|
@@ -1360,6 +1402,10 @@ function inferFlowSuccessSignal(flow) {
|
|
|
1360
1402
|
if (/\bconfiguration verification\b/i.test(flow.title)) {
|
|
1361
1403
|
return "the affected build or runtime variant starts cleanly and handles fallback values";
|
|
1362
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
|
+
}
|
|
1363
1409
|
const verificationStep = flow.steps.find((step) => isAssertionStep(step));
|
|
1364
1410
|
if (verificationStep) {
|
|
1365
1411
|
return stripTerminalPunctuation(verificationStep);
|
|
@@ -1370,32 +1416,36 @@ function inferFlowSuccessSignal(flow) {
|
|
|
1370
1416
|
}
|
|
1371
1417
|
return "the changed journey reaches a visible, stable success state";
|
|
1372
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
|
+
}
|
|
1373
1423
|
function inferFlowReviewQuestion(flow, successSignal) {
|
|
1374
1424
|
if (isApiContractFocusedFlow(flow)) {
|
|
1375
|
-
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}?`;
|
|
1376
1426
|
}
|
|
1377
1427
|
if (isDesignTokenFocusedFlow(flow)) {
|
|
1378
|
-
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}?`;
|
|
1379
1429
|
}
|
|
1380
1430
|
if (isCatalogFocusedFlow(flow)) {
|
|
1381
|
-
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}?`;
|
|
1382
1432
|
}
|
|
1383
1433
|
if (isTestEvidenceFocusedFlow(flow)) {
|
|
1384
|
-
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}?`;
|
|
1385
1435
|
}
|
|
1386
1436
|
if (isDocumentationFocusedFlow(flow)) {
|
|
1387
|
-
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}?`;
|
|
1388
1438
|
}
|
|
1389
1439
|
if (isGeneratedArtifactFocusedFlow(flow)) {
|
|
1390
|
-
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}?`;
|
|
1391
1441
|
}
|
|
1392
1442
|
if (isCliCommandFocusedFlow(flow)) {
|
|
1393
|
-
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}?`;
|
|
1394
1444
|
}
|
|
1395
1445
|
if (/\bconfiguration verification\b/i.test(flow.title)) {
|
|
1396
|
-
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}?`;
|
|
1397
1447
|
}
|
|
1398
|
-
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}?`;
|
|
1399
1449
|
}
|
|
1400
1450
|
function isApiContractFocusedFlow(flow) {
|
|
1401
1451
|
if (isEvidenceVerificationFocusedFlow(flow)) {
|
|
@@ -1792,7 +1842,7 @@ export function formatMarkdownE2eDraft(result) {
|
|
|
1792
1842
|
lines.push("");
|
|
1793
1843
|
lines.push("## Draft Readiness Summary");
|
|
1794
1844
|
lines.push("");
|
|
1795
|
-
lines.push(`
|
|
1845
|
+
lines.push(`Stage: ${formatDraftReadinessStage(result.readinessSummary)}. ${escapeMarkdownInline(result.readinessSummary.recommendation)}`);
|
|
1796
1846
|
lines.push(`Summary: ${result.actionSummary.required} required action${result.actionSummary.required === 1 ? "" : "s"}, ${result.actionSummary.recommended} recommended action${result.actionSummary.recommended === 1 ? "" : "s"}.`);
|
|
1797
1847
|
lines.push(`- Runnable candidates: ${result.readinessSummary.runnableCandidates}`);
|
|
1798
1848
|
lines.push(`- Near-runnable files: ${result.readinessSummary.nearRunnable}`);
|
|
@@ -1906,7 +1956,7 @@ export function formatMarkdownE2eSetup(result) {
|
|
|
1906
1956
|
lines.push(`- Output directory: \`${escapeMarkdownInline(result.draftOutputDirectory)}\``);
|
|
1907
1957
|
}
|
|
1908
1958
|
if (result.draftReadinessSummary) {
|
|
1909
|
-
lines.push(`-
|
|
1959
|
+
lines.push(`- Stage: ${formatDraftReadinessStage(result.draftReadinessSummary)}`);
|
|
1910
1960
|
}
|
|
1911
1961
|
for (const file of result.draftFiles) {
|
|
1912
1962
|
const details = [
|
|
@@ -2221,6 +2271,21 @@ function recommendRunner(project) {
|
|
|
2221
2271
|
reason: "No clear app platform was detected, so start with a manual smoke checklist before choosing a runnable E2E framework.",
|
|
2222
2272
|
};
|
|
2223
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
|
+
}
|
|
2224
2289
|
function overrideRunner(project, runner) {
|
|
2225
2290
|
if (runner === "maestro") {
|
|
2226
2291
|
return {
|
|
@@ -3049,7 +3114,13 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
|
|
|
3049
3114
|
const behaviorFiles = files.filter((file) => !isTestLikeFile(file));
|
|
3050
3115
|
const candidateFiles = behaviorFiles.length > 0 ? behaviorFiles : files;
|
|
3051
3116
|
const impactSurfaceFiles = importImpacts.map((impact) => impact.surface).filter((surface) => !candidateFiles.includes(surface));
|
|
3052
|
-
|
|
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)]);
|
|
3053
3124
|
const apiFiles = candidateFiles.filter(isApiLikeFile);
|
|
3054
3125
|
const apiServiceSourceFiles = projectType === "api-service"
|
|
3055
3126
|
? candidateFiles.filter((file) => !apiFiles.includes(file) && !isConfigLikeFile(file) && isServiceSourceFile(file))
|
|
@@ -3067,7 +3138,8 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
|
|
|
3067
3138
|
const domainFiles = candidateFiles.filter(isDomainOwnedFile);
|
|
3068
3139
|
const candidates = [];
|
|
3069
3140
|
if (uiFiles.length > 0) {
|
|
3070
|
-
const
|
|
3141
|
+
const subjectFiles = impactSurfaceFiles.length > 0 ? impactSurfaceFiles : uiFiles;
|
|
3142
|
+
const subject = summarizeFlowSubject(subjectFiles, "Changed", domainLanguage);
|
|
3071
3143
|
const impactReason = importImpacts.length > 0
|
|
3072
3144
|
? ` Changed shared files reach these surfaces through imports: ${importImpacts.slice(0, 3).map(describeImportChain).join("; ")}.`
|
|
3073
3145
|
: "";
|
|
@@ -3186,7 +3258,9 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
|
|
|
3186
3258
|
if (configFiles.length > 0) {
|
|
3187
3259
|
const subject = isReleaseMetadataOnlyChange(configFiles)
|
|
3188
3260
|
? "Release metadata"
|
|
3189
|
-
:
|
|
3261
|
+
: (projectType === "expo-react-native" || projectType === "react-native") && configFiles.some(isMobileNativeConfigFile)
|
|
3262
|
+
? "Mobile build"
|
|
3263
|
+
: summarizeFlowSubject(configFiles, "Changed", domainLanguage);
|
|
3190
3264
|
candidates.push({
|
|
3191
3265
|
kind: "config",
|
|
3192
3266
|
title: `${subject} configuration verification ${runner === "manual" ? "checklist" : "flow"}`,
|
|
@@ -3315,7 +3389,10 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
|
|
|
3315
3389
|
}
|
|
3316
3390
|
const coverage = buildCoverageTargets(candidate.kind, files, runner);
|
|
3317
3391
|
const setupHints = await inferFlowSetupHints(root, files, candidate.kind);
|
|
3318
|
-
const
|
|
3392
|
+
const interactionEvidenceApplies = !isVerificationOnlyKind(candidate.kind);
|
|
3393
|
+
const selectors = interactionEvidenceApplies
|
|
3394
|
+
? await inferFlowSelectors(root, files, runner, addedDiffText)
|
|
3395
|
+
: [];
|
|
3319
3396
|
const flow = {
|
|
3320
3397
|
title: candidate.title,
|
|
3321
3398
|
reason: candidate.reason,
|
|
@@ -3323,17 +3400,20 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
|
|
|
3323
3400
|
steps: refineStepsForInferredSelectors(candidate.steps, selectors),
|
|
3324
3401
|
coverage,
|
|
3325
3402
|
coverageEvidence: evaluateFlowCoverageEvidence({ title: candidate.title, files, coverage }, testSuiteInventory),
|
|
3326
|
-
entrypoints: await inferFlowEntrypoints(root, files, runner),
|
|
3403
|
+
entrypoints: interactionEvidenceApplies ? await inferFlowEntrypoints(root, files, runner) : [],
|
|
3327
3404
|
setupHints,
|
|
3328
3405
|
fixtureReadiness: await inferFlowFixtureReadiness(root, files, candidate.kind, setupHints, fixtureContext),
|
|
3329
3406
|
selectors,
|
|
3330
|
-
missingTestability: await findFlowTestabilityGaps(root, files, runner),
|
|
3407
|
+
missingTestability: interactionEvidenceApplies ? await findFlowTestabilityGaps(root, files, runner) : [],
|
|
3331
3408
|
};
|
|
3332
3409
|
return {
|
|
3333
3410
|
...flow,
|
|
3334
3411
|
languageBrief: buildFlowLanguageBrief(flow),
|
|
3335
3412
|
};
|
|
3336
3413
|
}
|
|
3414
|
+
function isVerificationOnlyKind(kind) {
|
|
3415
|
+
return kind === "config" || kind === "test-evidence" || kind === "documentation" || kind === "generated-artifact";
|
|
3416
|
+
}
|
|
3337
3417
|
function preferDiffAdded(selectors, predicate) {
|
|
3338
3418
|
return selectors.find((selector) => selector.addedInDiff && predicate(selector)) ?? selectors.find(predicate);
|
|
3339
3419
|
}
|
|
@@ -3387,7 +3467,11 @@ function selectorStepLabel(selector) {
|
|
|
3387
3467
|
return raw.length > 0 ? `"${raw}"` : `the ${selector.kind} control`;
|
|
3388
3468
|
}
|
|
3389
3469
|
function actionVerbForSelector(selector) {
|
|
3390
|
-
|
|
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)) {
|
|
3391
3475
|
return "Submit";
|
|
3392
3476
|
}
|
|
3393
3477
|
return "Activate";
|
|
@@ -3464,12 +3548,38 @@ function setupHint(kind, title, detail, files, confidence) {
|
|
|
3464
3548
|
confidence,
|
|
3465
3549
|
};
|
|
3466
3550
|
}
|
|
3551
|
+
const maxAnalyzedMockFiles = 24;
|
|
3467
3552
|
async function collectFixtureReadinessContext(root, changedFiles) {
|
|
3468
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
|
+
}
|
|
3469
3578
|
return {
|
|
3470
3579
|
changedBackendFiles: changedFiles.filter(isBackendImplementationFile).slice(0, maxFilesPerFlow),
|
|
3471
|
-
changedMockFiles
|
|
3472
|
-
projectMockFiles:
|
|
3580
|
+
changedMockFiles,
|
|
3581
|
+
projectMockFiles: projectMockEntries.map((file) => file.path).slice(0, maxFilesPerFlow),
|
|
3582
|
+
mockFileInsights,
|
|
3473
3583
|
};
|
|
3474
3584
|
}
|
|
3475
3585
|
async function inferFlowFixtureReadiness(root, files, kind, setupHints, context) {
|
|
@@ -3541,6 +3651,7 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3541
3651
|
const fallbackProjectMockSignals = projectMockSignals.length > 0 ? projectMockSignals : context.projectMockFiles;
|
|
3542
3652
|
const mockSignals = uniqueStrings([...changedMockSignals, ...fallbackProjectMockSignals]).slice(0, maxFilesPerFlow);
|
|
3543
3653
|
if (changedMockSignals.length > 0) {
|
|
3654
|
+
const changedInsights = insightsForMockSignals(changedMockSignals, context);
|
|
3544
3655
|
return {
|
|
3545
3656
|
status: "ready",
|
|
3546
3657
|
reason: "This branch includes mock or fixture evidence for an API-dependent flow.",
|
|
@@ -3549,13 +3660,15 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3549
3660
|
backendSignals,
|
|
3550
3661
|
mockSignals,
|
|
3551
3662
|
nextActions: [
|
|
3552
|
-
`Keep changed fixture/mock evidence aligned with this flow: ${
|
|
3663
|
+
`Keep changed fixture/mock evidence aligned with this flow: ${describeFixtureEvidence(changedMockSignals, changedInsights)}.`,
|
|
3553
3664
|
"Keep deterministic success, empty, unauthorized, and failure fixture cases aligned with the changed flow.",
|
|
3554
3665
|
],
|
|
3666
|
+
...(changedInsights.length > 0 ? { mockInsights: changedInsights } : {}),
|
|
3555
3667
|
};
|
|
3556
3668
|
}
|
|
3557
3669
|
if (context.projectMockFiles.length > 0) {
|
|
3558
3670
|
const reusableMockSignals = mockSignals.length > 0 ? mockSignals : context.projectMockFiles;
|
|
3671
|
+
const reusableInsights = insightsForMockSignals(reusableMockSignals, context);
|
|
3559
3672
|
return {
|
|
3560
3673
|
status: "partial",
|
|
3561
3674
|
reason: "Mock or fixture infrastructure exists, but this branch does not add flow-specific fixture evidence.",
|
|
@@ -3564,9 +3677,10 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3564
3677
|
backendSignals,
|
|
3565
3678
|
mockSignals,
|
|
3566
3679
|
nextActions: [
|
|
3567
|
-
|
|
3680
|
+
reuseFixtureAction(reusableMockSignals, reusableInsights, apiEndpoints),
|
|
3568
3681
|
"Cover the primary success response and one empty, rejected, or server-error response.",
|
|
3569
3682
|
],
|
|
3683
|
+
...(reusableInsights.length > 0 ? { mockInsights: reusableInsights } : {}),
|
|
3570
3684
|
};
|
|
3571
3685
|
}
|
|
3572
3686
|
if (backendSignals.length > 0) {
|
|
@@ -3578,7 +3692,9 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3578
3692
|
backendSignals,
|
|
3579
3693
|
mockSignals,
|
|
3580
3694
|
nextActions: [
|
|
3581
|
-
|
|
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.",
|
|
3582
3698
|
"Seed realistic response data for success and failure paths.",
|
|
3583
3699
|
],
|
|
3584
3700
|
};
|
|
@@ -3591,11 +3707,59 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
|
|
|
3591
3707
|
backendSignals,
|
|
3592
3708
|
mockSignals,
|
|
3593
3709
|
nextActions: [
|
|
3594
|
-
|
|
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.",
|
|
3595
3713
|
"Include success plus one empty, unauthorized, rejected, timeout, or server-error response.",
|
|
3596
3714
|
],
|
|
3597
3715
|
};
|
|
3598
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
|
+
}
|
|
3599
3763
|
async function findApiDependencySignals(root, files, kind, setupHints) {
|
|
3600
3764
|
const signals = new Set();
|
|
3601
3765
|
if (kind === "api") {
|
|
@@ -3658,7 +3822,7 @@ function normalizeApiEndpointHint(value) {
|
|
|
3658
3822
|
if (!/(?:\/api(?:\/|$)|\/graphql(?:\/|$)|\/trpc(?:\/|$))/i.test(endpoint)) {
|
|
3659
3823
|
return undefined;
|
|
3660
3824
|
}
|
|
3661
|
-
if (/[\s<>{}]/.test(endpoint)) {
|
|
3825
|
+
if (/[\s<>{}`]/.test(endpoint)) {
|
|
3662
3826
|
return undefined;
|
|
3663
3827
|
}
|
|
3664
3828
|
return endpoint;
|
|
@@ -3703,10 +3867,40 @@ function isMockOrFixtureFile(file) {
|
|
|
3703
3867
|
const stem = basename.replace(/\.[^.]+$/g, "");
|
|
3704
3868
|
const extension = path.extname(basename).toLowerCase();
|
|
3705
3869
|
const canUseBroadFilenameMatch = fixtureEvidenceSourceExtensions.has(extension);
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
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
|
+
]);
|
|
3710
3904
|
function isFixtureEvidenceIgnoredPath(file) {
|
|
3711
3905
|
return /(?:^|\/)(?:node_modules|vendor|vendors|Pods|build|dist|coverage|\.next|\.nuxt|\.expo|\.turbo|\.yarn\/cache)\//i.test(file);
|
|
3712
3906
|
}
|
|
@@ -4080,7 +4274,7 @@ async function buildSetupNotes(root, runner, project) {
|
|
|
4080
4274
|
return ["Choose an E2E runner after documenting the primary user-facing entry point for this project."];
|
|
4081
4275
|
}
|
|
4082
4276
|
function isUserFacingFile(file) {
|
|
4083
|
-
if (isApiRouteFile(file)) {
|
|
4277
|
+
if (isApiRouteFile(file) || isConfigLikeFile(file) || isTestLikeFile(file)) {
|
|
4084
4278
|
return false;
|
|
4085
4279
|
}
|
|
4086
4280
|
return (/(?:^|\/)(app|pages|routes|screens|components|ui|navigation)\//i.test(file) ||
|
|
@@ -4136,7 +4330,13 @@ function isCatalogDataFile(file) {
|
|
|
4136
4330
|
/(?:^|\/)(?:catalog|taxonomy)-site\/index\.html$/i.test(file);
|
|
4137
4331
|
}
|
|
4138
4332
|
function isConfigLikeFile(file) {
|
|
4139
|
-
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);
|
|
4140
4340
|
}
|
|
4141
4341
|
function isReleaseMetadataFile(file) {
|
|
4142
4342
|
return /(?:^|\/)(?:CHANGELOG|RELEASES?|release-notes?|\.release-please-manifest)\.(?:md|json)$/i.test(file) ||
|
|
@@ -4169,7 +4369,11 @@ function isTestLikeFile(file) {
|
|
|
4169
4369
|
/(?:^|\/)test_[^/]+\.py$/i.test(file) ||
|
|
4170
4370
|
/(?:^|\/)[^/]+_test\.(?:py|go)$/i.test(file) ||
|
|
4171
4371
|
/(?:^|\/)[^/]+(?:Test|Tests|Spec)\.(?:java|kt|cs|swift)$/i.test(file) ||
|
|
4172
|
-
/(?:^|\/)[^/]+_(?: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);
|
|
4173
4377
|
}
|
|
4174
4378
|
function isDocumentationFile(file) {
|
|
4175
4379
|
if (isReleaseMetadataFile(file)) {
|
|
@@ -4540,7 +4744,7 @@ function isImportantBaseDraftFlow(flow) {
|
|
|
4540
4744
|
}
|
|
4541
4745
|
function shouldUseDomainScenariosForDraft(plan) {
|
|
4542
4746
|
const files = plan.changedFiles.map((file) => file.path);
|
|
4543
|
-
if (isLowSignalVerificationOnlyChange(files)) {
|
|
4747
|
+
if (isLowSignalVerificationOnlyChange(files) || isConfigurationOnlyChange(files)) {
|
|
4544
4748
|
return false;
|
|
4545
4749
|
}
|
|
4546
4750
|
return !plan.flows.every(isEvidenceVerificationFocusedFlow);
|
|
@@ -4548,6 +4752,9 @@ function shouldUseDomainScenariosForDraft(plan) {
|
|
|
4548
4752
|
function isEvidenceVerificationFocusedFlow(flow) {
|
|
4549
4753
|
return isTestEvidenceFocusedFlow(flow) || isDocumentationFocusedFlow(flow) || isGeneratedArtifactFocusedFlow(flow);
|
|
4550
4754
|
}
|
|
4755
|
+
function isVerificationOnlyFlow(flow) {
|
|
4756
|
+
return isEvidenceVerificationFocusedFlow(flow) || /\bconfiguration verification\b/i.test(flow.title);
|
|
4757
|
+
}
|
|
4551
4758
|
function dedupeDraftFlowsByOutputPath(flows, runner) {
|
|
4552
4759
|
const flowsByOutput = new Map();
|
|
4553
4760
|
for (const flow of flows) {
|
|
@@ -4615,7 +4822,43 @@ async function buildManifestDraftFlows(plan, baseFlows) {
|
|
|
4615
4822
|
.filter((match) => match.kind === "flow")
|
|
4616
4823
|
.slice(0, 4);
|
|
4617
4824
|
const checkMatches = plan.verificationManifestMatches.filter((match) => match.kind === "check");
|
|
4618
|
-
|
|
4825
|
+
const flowDrafts = await Promise.all(flowMatches.map((match) => buildManifestDraftFlow(plan, match, checkMatches, baseFlows)));
|
|
4826
|
+
const flowMatchedFiles = new Set(flowMatches.flatMap((match) => match.matchedFiles));
|
|
4827
|
+
const domainDrafts = await Promise.all(plan.verificationManifestMatches
|
|
4828
|
+
.filter((match) => match.kind === "domain" && match.matchedFiles.some((file) => !flowMatchedFiles.has(file)))
|
|
4829
|
+
.slice(0, 4)
|
|
4830
|
+
.map((match) => buildManifestDomainDraftFlow(plan, match, baseFlows)));
|
|
4831
|
+
return [...flowDrafts, ...domainDrafts.filter((flow) => Boolean(flow))].slice(0, 4);
|
|
4832
|
+
}
|
|
4833
|
+
async function buildManifestDomainDraftFlow(plan, match, baseFlows) {
|
|
4834
|
+
const manifestFiles = normalizeScenarioFilesForRoot(plan, match.matchedFiles);
|
|
4835
|
+
const baseFlow = bestOverlappingBaseFlowForManifestMatch(manifestFiles, baseFlows);
|
|
4836
|
+
if (!baseFlow || isVerificationOnlyFlow(baseFlow)) {
|
|
4837
|
+
return undefined;
|
|
4838
|
+
}
|
|
4839
|
+
// A domain path can cover many independent flows. Preserve manifest
|
|
4840
|
+
// provenance on the best overlapping flow without claiming every matched
|
|
4841
|
+
// domain file belongs to that single draft.
|
|
4842
|
+
const files = baseFlow.files;
|
|
4843
|
+
const flow = {
|
|
4844
|
+
...baseFlow,
|
|
4845
|
+
reason: `${match.reason} ${baseFlow.reason}`,
|
|
4846
|
+
files,
|
|
4847
|
+
entrypoints: uniqueEntrypoints([
|
|
4848
|
+
...baseFlow.entrypoints,
|
|
4849
|
+
...(await inferFlowEntrypoints(plan.root, files, plan.recommendedRunner.name)),
|
|
4850
|
+
]),
|
|
4851
|
+
selectors: uniqueSelectors([
|
|
4852
|
+
...baseFlow.selectors,
|
|
4853
|
+
...(await inferFlowSelectors(plan.root, files, plan.recommendedRunner.name)),
|
|
4854
|
+
]),
|
|
4855
|
+
draftSource: "verification-manifest",
|
|
4856
|
+
manifestMatch: match,
|
|
4857
|
+
};
|
|
4858
|
+
return {
|
|
4859
|
+
...flow,
|
|
4860
|
+
languageBrief: buildFlowLanguageBrief(flow),
|
|
4861
|
+
};
|
|
4619
4862
|
}
|
|
4620
4863
|
async function buildManifestDraftFlow(plan, match, checkMatches, baseFlows) {
|
|
4621
4864
|
const relatedChecks = checkMatches.filter((check) => check.id.startsWith(`${match.id}.`));
|
|
@@ -4672,6 +4915,12 @@ function bestBaseFlowForManifestMatch(files, baseFlows) {
|
|
|
4672
4915
|
}
|
|
4673
4916
|
return best && best.score > 0 ? best.flow : baseFlows[0];
|
|
4674
4917
|
}
|
|
4918
|
+
function bestOverlappingBaseFlowForManifestMatch(files, baseFlows) {
|
|
4919
|
+
const ranked = baseFlows
|
|
4920
|
+
.map((flow) => ({ flow, score: fileOverlapScore(files, flow.files) }))
|
|
4921
|
+
.sort((left, right) => right.score - left.score);
|
|
4922
|
+
return ranked[0]?.score > 0 ? ranked[0].flow : undefined;
|
|
4923
|
+
}
|
|
4675
4924
|
function coreFlowForManifestMatch(plan, match) {
|
|
4676
4925
|
return plan.coreFlows.find((flow) => flow.name === match.name || flow.id === match.id);
|
|
4677
4926
|
}
|
|
@@ -4742,7 +4991,11 @@ async function buildDomainScenarioDraftFlow(plan, scenario, baseFlows) {
|
|
|
4742
4991
|
const reason = specializedScenario?.reason ?? scenario.intent;
|
|
4743
4992
|
const steps = specializedScenario?.steps ?? (scenario.checks.length > 0 ? scenario.checks : (baseFlow?.steps ?? []));
|
|
4744
4993
|
const scenarioFiles = normalizeScenarioFilesForRoot(plan, scenario.files);
|
|
4745
|
-
const files = uniqueStrings(
|
|
4994
|
+
const files = uniqueStrings(specializedScenario?.useBaseFlowFiles
|
|
4995
|
+
? [...(baseFlow?.files ?? []), ...scenarioFiles]
|
|
4996
|
+
: scenarioFiles.length > 0
|
|
4997
|
+
? scenarioFiles
|
|
4998
|
+
: (baseFlow?.files ?? [])).slice(0, 20);
|
|
4746
4999
|
const coverage = baseFlow?.coverage ?? buildCoverageTargets("domain", files, plan.recommendedRunner.name);
|
|
4747
5000
|
const runner = plan.recommendedRunner.name;
|
|
4748
5001
|
const entrypoints = uniqueEntrypoints([
|
|
@@ -4794,6 +5047,15 @@ function specializedDomainScenarioDraft(scenario, baseFlow) {
|
|
|
4794
5047
|
if (scenario.source !== "changed-file" || !baseFlow) {
|
|
4795
5048
|
return undefined;
|
|
4796
5049
|
}
|
|
5050
|
+
if (/through imports/i.test(baseFlow.reason) && /\sprimary journey$/i.test(scenario.title)) {
|
|
5051
|
+
const subject = baseFlow.title.replace(/\s+UI smoke flow$/i, "").trim();
|
|
5052
|
+
return {
|
|
5053
|
+
title: subject,
|
|
5054
|
+
reason: baseFlow.reason,
|
|
5055
|
+
steps: baseFlow.steps,
|
|
5056
|
+
useBaseFlowFiles: true,
|
|
5057
|
+
};
|
|
5058
|
+
}
|
|
4797
5059
|
if (isApiContractFocusedFlow(baseFlow)) {
|
|
4798
5060
|
const subject = scenario.title.replace(/\s+primary journey$/i, "");
|
|
4799
5061
|
return {
|
|
@@ -4945,7 +5207,7 @@ function domainScenarioForFlow(flow) {
|
|
|
4945
5207
|
return flow.domainScenario;
|
|
4946
5208
|
}
|
|
4947
5209
|
function draftStability(plan, flow) {
|
|
4948
|
-
const needsSelector = flow.missingTestability.length > 0;
|
|
5210
|
+
const needsSelector = !isVerificationOnlyFlow(flow) && flow.missingTestability.length > 0;
|
|
4949
5211
|
const needsSetup = plan.missingTestability.some((gap) => /No \.maestro|No Playwright config/i.test(gap));
|
|
4950
5212
|
if (needsSelector && needsSetup) {
|
|
4951
5213
|
return "needs-selector-and-setup";
|
|
@@ -4959,14 +5221,15 @@ function draftStability(plan, flow) {
|
|
|
4959
5221
|
return "ready";
|
|
4960
5222
|
}
|
|
4961
5223
|
function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck) {
|
|
4962
|
-
const
|
|
4963
|
-
|
|
5224
|
+
const verificationOnly = isVerificationOnlyFlow(flow);
|
|
5225
|
+
const blockers = verificationOnly ? [] : [...plan.executionProfile.blockers];
|
|
5226
|
+
if (!verificationOnly && runner !== "manual" && flow.entrypoints.length === 0) {
|
|
4964
5227
|
blockers.push("No runnable route, screen, or command entrypoint was inferred for this flow.");
|
|
4965
5228
|
}
|
|
4966
|
-
if (flow.missingTestability.length > 0) {
|
|
5229
|
+
if (!verificationOnly && flow.missingTestability.length > 0) {
|
|
4967
5230
|
blockers.push(flow.missingTestability[0]);
|
|
4968
5231
|
}
|
|
4969
|
-
if (flow.fixtureReadiness.status === "missing") {
|
|
5232
|
+
if (!verificationOnly && flow.fixtureReadiness.status === "missing") {
|
|
4970
5233
|
blockers.push(flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason);
|
|
4971
5234
|
}
|
|
4972
5235
|
if (validationSummary.blockingGapCount > 0) {
|
|
@@ -4978,7 +5241,7 @@ function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck
|
|
|
4978
5241
|
return uniqueStrings(blockers).slice(0, 8);
|
|
4979
5242
|
}
|
|
4980
5243
|
function draftRunnableStatus(plan, flow, runner, validationSummary, executionBlockers, selfCheck) {
|
|
4981
|
-
if (runner === "manual") {
|
|
5244
|
+
if (runner === "manual" || isVerificationOnlyFlow(flow)) {
|
|
4982
5245
|
return "review-only";
|
|
4983
5246
|
}
|
|
4984
5247
|
if (selfCheck?.status === "fail") {
|
|
@@ -5981,19 +6244,39 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
|
|
|
5981
6244
|
if (mockableEndpoints.length === 0) {
|
|
5982
6245
|
return;
|
|
5983
6246
|
}
|
|
6247
|
+
const insights = flow.fixtureReadiness.mockInsights ?? [];
|
|
6248
|
+
const shapeSources = [];
|
|
5984
6249
|
lines.push("");
|
|
5985
6250
|
lines.push(" const mockApiResponses = {");
|
|
5986
6251
|
for (const endpoint of mockableEndpoints.slice(0, maxFilesPerFlow)) {
|
|
6252
|
+
const insight = insights.find((candidate) => specSafeSampleKeys(candidate).length > 0 && insightCoversEndpoint(candidate, endpoint)) ??
|
|
6253
|
+
insights.find((candidate) => specSafeSampleKeys(candidate).length > 0);
|
|
5987
6254
|
lines.push(` "${quoteJs(playwrightMockRoutePattern(endpoint))}": {`);
|
|
5988
6255
|
lines.push(" status: 200,");
|
|
5989
6256
|
lines.push(" body: {");
|
|
5990
|
-
|
|
5991
|
-
|
|
6257
|
+
if (insight) {
|
|
6258
|
+
if (!shapeSources.includes(insight.file)) {
|
|
6259
|
+
shapeSources.push(insight.file);
|
|
6260
|
+
}
|
|
6261
|
+
for (const key of specSafeSampleKeys(insight)) {
|
|
6262
|
+
const propertyName = isJsIdentifier(key) ? key : JSON.stringify(key);
|
|
6263
|
+
lines.push(` ${propertyName}: ${JSON.stringify(`qamap-${key}`)},`);
|
|
6264
|
+
}
|
|
6265
|
+
}
|
|
6266
|
+
else {
|
|
6267
|
+
lines.push(' ok: true,');
|
|
6268
|
+
lines.push(' source: "qamap-draft",');
|
|
6269
|
+
}
|
|
5992
6270
|
lines.push(" },");
|
|
5993
6271
|
lines.push(" },");
|
|
5994
6272
|
}
|
|
5995
6273
|
lines.push(" };");
|
|
5996
|
-
|
|
6274
|
+
if (shapeSources.length > 0) {
|
|
6275
|
+
lines.push(` // Response shape keys reuse ${formatFileSummary(shapeSources)}; replace the sample values with deterministic fixture data before promoting this draft.`);
|
|
6276
|
+
}
|
|
6277
|
+
else {
|
|
6278
|
+
lines.push(" // Replace sample responses with deterministic fixtures from the target domain before promoting this draft.");
|
|
6279
|
+
}
|
|
5997
6280
|
lines.push(" for (const [urlPattern, response] of Object.entries(mockApiResponses)) {");
|
|
5998
6281
|
lines.push(" await page.route(urlPattern, async (route) => {");
|
|
5999
6282
|
lines.push(" await route.fulfill({");
|
|
@@ -6004,6 +6287,11 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
|
|
|
6004
6287
|
lines.push(" });");
|
|
6005
6288
|
lines.push(" }");
|
|
6006
6289
|
}
|
|
6290
|
+
// Drafts are pinned to contain no literal TODO marker, so a fixture key that
|
|
6291
|
+
// would introduce one disqualifies itself from spec interpolation.
|
|
6292
|
+
function specSafeSampleKeys(insight) {
|
|
6293
|
+
return insight.sampleKeys.filter((key) => !/todo/i.test(key));
|
|
6294
|
+
}
|
|
6007
6295
|
function observedEndpointsForFlow(flow) {
|
|
6008
6296
|
if (flow.fixtureReadiness.status === "not-needed" || flow.fixtureReadiness.apiEndpoints.length === 0) {
|
|
6009
6297
|
return [];
|
|
@@ -6794,6 +7082,28 @@ function extractTextNodeSelectors(file, text) {
|
|
|
6794
7082
|
for (const selector of extractAttributeSelectors(file, text, ["title"], "visible-text")) {
|
|
6795
7083
|
selectors.push(selector);
|
|
6796
7084
|
}
|
|
7085
|
+
selectors.push(...extractRenderedStateSelectors(file, text));
|
|
7086
|
+
return selectors;
|
|
7087
|
+
}
|
|
7088
|
+
function extractRenderedStateSelectors(file, text) {
|
|
7089
|
+
const selectors = [];
|
|
7090
|
+
const renderedNames = new Set([...text.matchAll(/>\s*\{\s*([A-Za-z_$][\w$]*)\s*\}\s*</g)].map((match) => match[1]));
|
|
7091
|
+
for (const stateName of renderedNames) {
|
|
7092
|
+
const stateDeclaration = new RegExp(`\\bconst\\s*\\[\\s*${escapeRegExp(stateName)}\\s*,\\s*([A-Za-z_$][\\w$]*)\\s*\\]\\s*=\\s*useState\\b`).exec(text);
|
|
7093
|
+
const setterName = stateDeclaration?.[1];
|
|
7094
|
+
if (!setterName) {
|
|
7095
|
+
continue;
|
|
7096
|
+
}
|
|
7097
|
+
const setterCallMatcher = new RegExp(`\\b${escapeRegExp(setterName)}\\s*\\(([^\\n;]{0,300})\\)`, "g");
|
|
7098
|
+
for (const call of text.matchAll(setterCallMatcher)) {
|
|
7099
|
+
for (const literal of call[1].matchAll(/"([^"]+)"|'([^']+)'|`([^`]+)`/g)) {
|
|
7100
|
+
const value = normalizeSelectorValue(literal[1] ?? literal[2] ?? literal[3]);
|
|
7101
|
+
if (value && isUsefulSelector(value)) {
|
|
7102
|
+
selectors.push({ kind: "visible-text", value, file });
|
|
7103
|
+
}
|
|
7104
|
+
}
|
|
7105
|
+
}
|
|
7106
|
+
}
|
|
6797
7107
|
return selectors;
|
|
6798
7108
|
}
|
|
6799
7109
|
function extractRoleSelectorsFromText(file, text) {
|
|
@@ -7051,11 +7361,12 @@ function selectorRank(kind) {
|
|
|
7051
7361
|
return 4;
|
|
7052
7362
|
}
|
|
7053
7363
|
function slugify(value) {
|
|
7054
|
-
|
|
7364
|
+
const slug = value
|
|
7055
7365
|
.toLowerCase()
|
|
7056
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
7366
|
+
.replace(/[^a-z0-9\uAC00-\uD7A3]+/g, "-")
|
|
7057
7367
|
.replace(/^-+|-+$/g, "")
|
|
7058
7368
|
.slice(0, 80);
|
|
7369
|
+
return slug || "draft";
|
|
7059
7370
|
}
|
|
7060
7371
|
function quoteYaml(value) {
|
|
7061
7372
|
return JSON.stringify(value);
|