@ivorycanvas/qamap 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/e2e.js CHANGED
@@ -14,6 +14,7 @@ import { loadCoreFlowManifest, matchCoreFlows } from "./flows.js";
14
14
  import { loadVerificationManifest, matchVerificationManifest } from "./manifest.js";
15
15
  import { collectTestSuiteInventory, evaluateFlowCoverageEvidence, summarizeTestSuiteInventory, } from "./test-evidence.js";
16
16
  import { addedDiffTextFromEvidence, collectAddedDiffEvidence, collectAddedDiffText, generateTestPlan, } from "./test-plan.js";
17
+ import { routeQaScenario } from "./scenario-routing.js";
17
18
  import { TOOL_NAME, VERSION } from "./version.js";
18
19
  // Human reports render readiness as a stage on a fixed journey instead of a
19
20
  // verdict, so a first run reads as "you are at the start", not "you failed".
@@ -222,9 +223,10 @@ export async function generateE2eDraft(rootInput, options = {}) {
222
223
  const content = shouldSkip ? ((await readTextIfExists(filePath)) ?? "") : draftContentForFlow(plan, flow, runner, addedDiffText);
223
224
  const todoCount = countTodos(content);
224
225
  const selfCheck = evaluateDraftSelfCheck(plan, flow, runner, content, todoCount);
225
- const actionItems = buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck);
226
- const executionBlockers = draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck);
227
- const runnableStatus = draftRunnableStatus(plan, flow, runner, validationSummary, executionBlockers, selfCheck);
226
+ const scenarioAutomation = buildScenarioAutomationReceipts(flow, runner, content, selfCheck);
227
+ const actionItems = buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck, scenarioAutomation);
228
+ const executionBlockers = draftExecutionBlockers(plan, flow, runner, selfCheck, scenarioAutomation);
229
+ const runnableStatus = draftRunnableStatus(plan, flow, runner, executionBlockers, selfCheck);
228
230
  const fileDetails = draftFileDetails(flow);
229
231
  if (dryRun) {
230
232
  files.push({
@@ -243,6 +245,7 @@ export async function generateE2eDraft(rootInput, options = {}) {
243
245
  runnableStatus,
244
246
  executionBlockers,
245
247
  selfCheck,
248
+ scenarioAutomation,
246
249
  todoCount,
247
250
  entrypointCount: flow.entrypoints.length,
248
251
  primaryEntrypoint: primaryEntrypointLabel(flow),
@@ -274,6 +277,7 @@ export async function generateE2eDraft(rootInput, options = {}) {
274
277
  runnableStatus,
275
278
  executionBlockers,
276
279
  selfCheck,
280
+ scenarioAutomation,
277
281
  todoCount,
278
282
  entrypointCount: flow.entrypoints.length,
279
283
  primaryEntrypoint: primaryEntrypointLabel(flow),
@@ -304,6 +308,7 @@ export async function generateE2eDraft(rootInput, options = {}) {
304
308
  runnableStatus,
305
309
  executionBlockers,
306
310
  selfCheck,
311
+ scenarioAutomation,
307
312
  todoCount,
308
313
  entrypointCount: flow.entrypoints.length,
309
314
  primaryEntrypoint: primaryEntrypointLabel(flow),
@@ -331,7 +336,7 @@ export async function generateE2eDraft(rootInput, options = {}) {
331
336
  files,
332
337
  actionSummary,
333
338
  readinessSummary: summarizeDraftReadiness(files, actionSummary),
334
- nextSteps: buildDraftNextSteps(plan, runner),
339
+ nextSteps: buildDraftNextSteps(plan, runner, files),
335
340
  };
336
341
  }
337
342
  function buildCoverageTargets(kind, files, runner) {
@@ -1019,7 +1024,7 @@ function buildDraftPromotionGuidance(flow) {
1019
1024
  action: "Create a domain or flow manifest after the first real user journey is identified.",
1020
1025
  };
1021
1026
  }
1022
- function buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck) {
1027
+ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck, scenarioAutomation = []) {
1023
1028
  const items = [];
1024
1029
  const verificationOnly = isVerificationOnlyFlow(flow);
1025
1030
  const runnerGap = runnerSetupGap(plan, runner);
@@ -1061,6 +1066,14 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
1061
1066
  if (!verificationOnly && flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
1062
1067
  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.`));
1063
1068
  }
1069
+ const requiredScenarioGaps = scenarioAutomation.filter((receipt) => receipt.decision === "required" && receipt.status !== "compiled");
1070
+ if (!verificationOnly && requiredScenarioGaps.length > 0) {
1071
+ const details = requiredScenarioGaps.slice(0, 3).map((receipt) => {
1072
+ const blocker = receipt.blockers[0] ?? "the selected scenario has no executable action and assertion mapping";
1073
+ return `"${receipt.title}" is ${receipt.status}: ${blocker}`;
1074
+ });
1075
+ items.push(draftActionItem("assertion", "required", "Compile required QA scenarios into executable coverage", details.join(" ")));
1076
+ }
1064
1077
  if (!verificationOnly && selfCheck?.status === "fail") {
1065
1078
  items.push(draftActionItem("validation", "required", "Resolve generated draft self-check", selfCheck.blockers[0] ?? selfCheck.summary));
1066
1079
  }
@@ -1108,7 +1121,8 @@ function draftNeedsAssertionWork(selfCheck) {
1108
1121
  return true;
1109
1122
  }
1110
1123
  return selfCheck.checks.some((check) => (check.name === "Unresolved placeholders" && check.status !== "pass") ||
1111
- (check.name === "TODO comments" && check.status !== "pass"));
1124
+ (check.name === "TODO comments" && check.status !== "pass") ||
1125
+ (check.name === "Domain assertions" && check.status !== "pass"));
1112
1126
  }
1113
1127
  function runnerSetupGap(plan, runner) {
1114
1128
  if (runner === "playwright") {
@@ -1222,13 +1236,22 @@ function summarizeDraftReadiness(files, actionSummary) {
1222
1236
  const totalTodos = files.reduce((sum, file) => sum + (file.todoCount ?? 0), 0);
1223
1237
  const filesWithExecutionBlockers = files.filter((file) => (file.executionBlockers?.length ?? 0) > 0).length;
1224
1238
  const totalExecutionBlockers = files.reduce((sum, file) => sum + (file.executionBlockers?.length ?? 0), 0);
1239
+ const scenarioAutomation = files.flatMap((file) => file.scenarioAutomation ?? []);
1240
+ const requiredScenarios = scenarioAutomation.filter((receipt) => receipt.decision === "required").length;
1241
+ const recommendedScenarios = scenarioAutomation.filter((receipt) => receipt.decision === "recommended").length;
1242
+ const reviewOnlyScenarios = scenarioAutomation.filter((receipt) => receipt.decision === "review-only").length;
1243
+ const compiledScenarios = scenarioAutomation.filter((receipt) => receipt.status === "compiled").length;
1244
+ const partialScenarios = scenarioAutomation.filter((receipt) => receipt.status === "partial").length;
1245
+ const notCompiledScenarios = scenarioAutomation.filter((receipt) => receipt.status === "not-compiled").length;
1246
+ const requiredScenarioGaps = scenarioAutomation.filter((receipt) => receipt.decision === "required" && receipt.status !== "compiled").length;
1225
1247
  const topBlockers = topDraftReadinessBlockers(files);
1226
1248
  const statusScore = (runnableCandidates * 100 + nearRunnable * 75 + reviewOnly * 35) / totalFiles;
1227
1249
  const selfCheckPenalty = (selfCheckWarning * 10 + selfCheckFail * 25) / totalFiles;
1228
1250
  const requiredActionPenalty = Math.min(25, actionSummary.required * 3);
1229
1251
  const todoPenalty = Math.min(15, totalTodos);
1230
1252
  const blockerPenalty = Math.min(20, filesWithExecutionBlockers * 5);
1231
- const score = clampReadinessScore(Math.round(statusScore - selfCheckPenalty - requiredActionPenalty - todoPenalty - blockerPenalty));
1253
+ const scenarioPenalty = Math.min(20, requiredScenarioGaps * 4);
1254
+ const score = clampReadinessScore(Math.round(statusScore - selfCheckPenalty - requiredActionPenalty - todoPenalty - blockerPenalty - scenarioPenalty));
1232
1255
  const level = draftReadinessLevel(score, actionSummary.required, selfCheckFail, reviewOnly);
1233
1256
  return {
1234
1257
  score,
@@ -1244,6 +1267,13 @@ function summarizeDraftReadiness(files, actionSummary) {
1244
1267
  totalTodos,
1245
1268
  filesWithExecutionBlockers,
1246
1269
  totalExecutionBlockers,
1270
+ requiredScenarios,
1271
+ recommendedScenarios,
1272
+ reviewOnlyScenarios,
1273
+ compiledScenarios,
1274
+ partialScenarios,
1275
+ notCompiledScenarios,
1276
+ requiredScenarioGaps,
1247
1277
  topBlockers,
1248
1278
  };
1249
1279
  }
@@ -1921,9 +1951,13 @@ function appendChangeIntentMarkdown(lines, analysis) {
1921
1951
  lines.push(`${index + 1}. **${stage.kind}**: ${escapeMarkdownInline(stage.label)} [${stage.confidence}]`);
1922
1952
  });
1923
1953
  lines.push("");
1924
- lines.push("Required QA scenarios:");
1954
+ lines.push("Routed QA scenarios:");
1925
1955
  for (const scenario of intent.scenarios.slice(0, 4)) {
1956
+ const routing = routeQaScenario(scenario);
1926
1957
  lines.push(`- **${scenario.priority} / ${scenario.kind}**: ${escapeMarkdownInline(scenario.title)}`);
1958
+ lines.push(` - Routing: ${routing.decision} - ${escapeMarkdownInline(routing.reason)}`);
1959
+ lines.push(` - Evidence: ${routing.requiredEvidence.length} required diff source${routing.requiredEvidence.length === 1 ? "" : "s"}, ` +
1960
+ `${routing.referenceEvidence.length} reference source${routing.referenceEvidence.length === 1 ? "" : "s"}`);
1927
1961
  for (const step of scenario.steps.slice(0, 3)) {
1928
1962
  lines.push(` - Step: ${escapeMarkdownInline(step)}`);
1929
1963
  }
@@ -1999,6 +2033,11 @@ export function formatMarkdownE2eDraft(result) {
1999
2033
  lines.push(`- Self-checks: ${result.readinessSummary.selfCheckPass} pass, ${result.readinessSummary.selfCheckWarning} warning, ${result.readinessSummary.selfCheckFail} fail`);
2000
2034
  lines.push(`- TODO markers: ${result.readinessSummary.totalTodos} across ${result.readinessSummary.filesWithTodos} file${result.readinessSummary.filesWithTodos === 1 ? "" : "s"}`);
2001
2035
  lines.push(`- Execution blockers: ${result.readinessSummary.totalExecutionBlockers} across ${result.readinessSummary.filesWithExecutionBlockers} file${result.readinessSummary.filesWithExecutionBlockers === 1 ? "" : "s"}`);
2036
+ lines.push(`- Scenario routing: ${result.readinessSummary.requiredScenarios} required, ` +
2037
+ `${result.readinessSummary.recommendedScenarios} recommended, ${result.readinessSummary.reviewOnlyScenarios} review-only`);
2038
+ lines.push(`- Scenario automation: ${result.readinessSummary.compiledScenarios} compiled, ` +
2039
+ `${result.readinessSummary.partialScenarios} partial, ${result.readinessSummary.notCompiledScenarios} not compiled; ` +
2040
+ `${result.readinessSummary.requiredScenarioGaps} required gap${result.readinessSummary.requiredScenarioGaps === 1 ? "" : "s"}`);
2002
2041
  if (result.readinessSummary.topBlockers.length > 0) {
2003
2042
  lines.push("- Top blockers:");
2004
2043
  for (const blocker of result.readinessSummary.topBlockers.slice(0, 3)) {
@@ -2041,6 +2080,22 @@ export function formatMarkdownE2eDraft(result) {
2041
2080
  }
2042
2081
  lines.push("");
2043
2082
  }
2083
+ const filesWithScenarioAutomation = result.files.filter((file) => (file.scenarioAutomation?.length ?? 0) > 0);
2084
+ if (filesWithScenarioAutomation.length > 0) {
2085
+ lines.push("## Scenario Automation Receipts");
2086
+ lines.push("");
2087
+ for (const file of filesWithScenarioAutomation) {
2088
+ lines.push(`- \`${escapeMarkdownInline(file.flowTitle)}\` (${escapeMarkdownInline(file.path)})`);
2089
+ for (const receipt of file.scenarioAutomation ?? []) {
2090
+ lines.push(` - [${receipt.decision}] ${escapeMarkdownInline(receipt.title)}: ${receipt.status} ` +
2091
+ `(steps ${receipt.mappedSteps}/${receipt.totalSteps}, assertions ${receipt.mappedAssertions}/${receipt.totalAssertions})`);
2092
+ for (const blocker of receipt.blockers.slice(0, 2)) {
2093
+ lines.push(` - Blocker: ${escapeMarkdownInline(blocker)}`);
2094
+ }
2095
+ }
2096
+ }
2097
+ lines.push("");
2098
+ }
2044
2099
  const filesWithActionItems = result.files.filter((file) => (file.actionItems?.length ?? 0) > 0);
2045
2100
  if (filesWithActionItems.length > 0) {
2046
2101
  lines.push("## Draft Action Items");
@@ -5420,6 +5475,104 @@ function draftFlowSource(flow) {
5420
5475
  function domainScenarioForFlow(flow) {
5421
5476
  return flow.domainScenario;
5422
5477
  }
5478
+ function buildScenarioAutomationReceipts(flow, runner, content, selfCheck) {
5479
+ const scenarios = flow.qaScenarios ?? [];
5480
+ if (scenarios.length === 0) {
5481
+ return [];
5482
+ }
5483
+ const routedPlaywrightScenarios = runner === "playwright" ? playwrightRoutedScenarioDrafts(flow) : [];
5484
+ const primaryContent = content.split("// Routed QA scenario:", 1)[0] ?? content;
5485
+ return scenarios.map((scenario) => {
5486
+ const selection = routeQaScenario(scenario);
5487
+ const base = {
5488
+ scenarioId: scenario.id,
5489
+ title: scenario.title,
5490
+ kind: scenario.kind,
5491
+ priority: scenario.priority,
5492
+ decision: selection.decision,
5493
+ requiredSourceCount: selection.requiredEvidence.length,
5494
+ referenceSourceCount: selection.referenceEvidence.length,
5495
+ totalSteps: scenario.steps.length,
5496
+ totalAssertions: scenario.assertions.length,
5497
+ };
5498
+ if (selection.decision === "review-only" || runner === "manual") {
5499
+ return {
5500
+ ...base,
5501
+ status: "review-only",
5502
+ mappedSteps: 0,
5503
+ mappedAssertions: 0,
5504
+ blockers: [
5505
+ selection.decision === "review-only"
5506
+ ? selection.reason
5507
+ : "The repository has no executable browser or device adapter for this scenario, so it remains review evidence.",
5508
+ ],
5509
+ };
5510
+ }
5511
+ if (scenario.kind !== "primary") {
5512
+ const routed = routedPlaywrightScenarios.find((candidate) => candidate.scenarioId === scenario.id);
5513
+ if (routed) {
5514
+ const fullyMapped = routed.mappedSteps >= scenario.steps.length &&
5515
+ routed.mappedAssertions >= scenario.assertions.length;
5516
+ const blockers = [
5517
+ routed.mappedSteps < scenario.steps.length
5518
+ ? `${scenario.steps.length - routed.mappedSteps} selected action step${scenario.steps.length - routed.mappedSteps === 1 ? "" : "s"} remain outside executable coverage.`
5519
+ : undefined,
5520
+ routed.mappedAssertions < scenario.assertions.length
5521
+ ? `${scenario.assertions.length - routed.mappedAssertions} selected assertion${scenario.assertions.length - routed.mappedAssertions === 1 ? "" : "s"} remain outside executable coverage.`
5522
+ : undefined,
5523
+ ].filter((value) => Boolean(value));
5524
+ return {
5525
+ ...base,
5526
+ status: fullyMapped ? "compiled" : "partial",
5527
+ mappedSteps: routed.mappedSteps,
5528
+ mappedAssertions: routed.mappedAssertions,
5529
+ blockers,
5530
+ };
5531
+ }
5532
+ return {
5533
+ ...base,
5534
+ status: "not-compiled",
5535
+ mappedSteps: 0,
5536
+ mappedAssertions: 0,
5537
+ blockers: [
5538
+ `No deterministic ${scenario.kind} compiler matched a repository entrypoint, action locator, fixture boundary, and observable outcome.`,
5539
+ ],
5540
+ };
5541
+ }
5542
+ return primaryScenarioAutomationReceipt(base, runner, primaryContent, scenario, selfCheck);
5543
+ });
5544
+ }
5545
+ function primaryScenarioAutomationReceipt(base, runner, content, scenario, selfCheck) {
5546
+ const mappedSteps = scenario.steps.filter((step) => content.includes(`Step intent: ${step}`)).length;
5547
+ const assertionStepMarkers = scenario.assertions.filter((assertion) => content.includes(`Step intent: ${assertion}`)).length;
5548
+ const executableAssertions = runner === "maestro"
5549
+ ? content.match(/^- assertVisible:\s+(?!["']\.\*["']$).+$/gm)?.length ?? 0
5550
+ : content.match(/await expect\((?!page\.locator\(["']body["']\))/g)?.length ?? 0;
5551
+ const mappedAssertions = Math.min(assertionStepMarkers, executableAssertions);
5552
+ const fallbackCount = content.match(/QAMap could not infer a stable (?:locator|Maestro selector)/g)?.length ?? 0;
5553
+ const blockers = [];
5554
+ if (selfCheck?.status === "fail") {
5555
+ blockers.push(selfCheck.blockers[0] ?? selfCheck.summary);
5556
+ }
5557
+ if (mappedSteps < scenario.steps.length) {
5558
+ blockers.push(`${scenario.steps.length - mappedSteps} selected action step${scenario.steps.length - mappedSteps === 1 ? "" : "s"} did not map to generated commands.`);
5559
+ }
5560
+ if (mappedAssertions < scenario.assertions.length) {
5561
+ blockers.push(`${scenario.assertions.length - mappedAssertions} selected assertion${scenario.assertions.length - mappedAssertions === 1 ? "" : "s"} did not map to an observable generated assertion.`);
5562
+ }
5563
+ if (fallbackCount > 0) {
5564
+ blockers.push(`${fallbackCount} generated step${fallbackCount === 1 ? "" : "s"} still use fallback smoke behavior.`);
5565
+ }
5566
+ const fullyMapped = selfCheck?.status !== "fail" && blockers.length === 0;
5567
+ const partiallyMapped = mappedSteps > 0 || mappedAssertions > 0;
5568
+ return {
5569
+ ...base,
5570
+ status: fullyMapped ? "compiled" : partiallyMapped ? "partial" : "not-compiled",
5571
+ mappedSteps,
5572
+ mappedAssertions,
5573
+ blockers: uniqueStrings(blockers),
5574
+ };
5575
+ }
5423
5576
  function draftStability(plan, flow) {
5424
5577
  const needsSelector = !isVerificationOnlyFlow(flow) && flow.missingTestability.length > 0;
5425
5578
  const needsSetup = plan.missingTestability.some((gap) => /No \.maestro|No Playwright config/i.test(gap));
@@ -5434,7 +5587,7 @@ function draftStability(plan, flow) {
5434
5587
  }
5435
5588
  return "ready";
5436
5589
  }
5437
- function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck) {
5590
+ function draftExecutionBlockers(plan, flow, runner, selfCheck, scenarioAutomation = []) {
5438
5591
  const verificationOnly = isVerificationOnlyFlow(flow);
5439
5592
  const blockers = verificationOnly ? [] : [...plan.executionProfile.blockers];
5440
5593
  if (!verificationOnly && runner !== "manual" && flow.entrypoints.length === 0) {
@@ -5446,15 +5599,20 @@ function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck
5446
5599
  if (!verificationOnly && flow.fixtureReadiness.status === "missing") {
5447
5600
  blockers.push(flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason);
5448
5601
  }
5449
- if (validationSummary.blockingGapCount > 0) {
5450
- blockers.push(`${validationSummary.blockingGapCount} blocking validation gap${validationSummary.blockingGapCount === 1 ? "" : "s"} ${validationSummary.blockingGapCount === 1 ? "remains" : "remain"}.`);
5451
- }
5602
+ // Missing validation evidence describes the repository before this draft exists.
5603
+ // Keep it as PR guidance, but do not treat it as proof that the generated file cannot execute.
5452
5604
  if (selfCheck?.status === "fail") {
5453
5605
  blockers.push(...selfCheck.blockers);
5454
5606
  }
5607
+ if (!verificationOnly && runner !== "manual") {
5608
+ for (const receipt of scenarioAutomation.filter((item) => item.decision === "required" && item.status !== "compiled")) {
5609
+ blockers.push(`Required QA scenario "${receipt.title}" is ${receipt.status}: ` +
5610
+ (receipt.blockers[0] ?? "no executable action and assertion mapping was produced."));
5611
+ }
5612
+ }
5455
5613
  return uniqueStrings(blockers).slice(0, 8);
5456
5614
  }
5457
- function draftRunnableStatus(plan, flow, runner, validationSummary, executionBlockers, selfCheck) {
5615
+ function draftRunnableStatus(plan, flow, runner, executionBlockers, selfCheck) {
5458
5616
  if (runner === "manual" || isVerificationOnlyFlow(flow)) {
5459
5617
  return "review-only";
5460
5618
  }
@@ -5462,9 +5620,8 @@ function draftRunnableStatus(plan, flow, runner, validationSummary, executionBlo
5462
5620
  return "review-only";
5463
5621
  }
5464
5622
  if (executionBlockers.length === 0 &&
5465
- validationSummary.blockingGapCount === 0 &&
5466
5623
  flow.missingTestability.length === 0 &&
5467
- (flow.fixtureReadiness.status === "ready" || flow.fixtureReadiness.status === "not-needed") &&
5624
+ flow.fixtureReadiness.status !== "missing" &&
5468
5625
  selfCheck?.status === "pass") {
5469
5626
  return "runnable-candidate";
5470
5627
  }
@@ -5502,6 +5659,10 @@ function evaluatePlaywrightDraftSelfCheck(plan, flow, content, todoCount) {
5502
5659
  checks.push(draftSelfCheckItem("TODO comments", todoCount === 0 ? "pass" : "warning", todoCount === 0
5503
5660
  ? "No TODO comments remain in the generated draft."
5504
5661
  : `${todoCount} TODO marker${todoCount === 1 ? "" : "s"} remain for reviewer follow-up.`));
5662
+ const weakSmokeAssertions = content.match(/expect\(page\.locator\(["']body["']\)\)\.toBeVisible\(\)/g)?.length ?? 0;
5663
+ checks.push(draftSelfCheckItem("Domain assertions", weakSmokeAssertions === 0 ? "pass" : "warning", weakSmokeAssertions === 0
5664
+ ? "The draft does not rely on body-only smoke assertions."
5665
+ : `${weakSmokeAssertions} body-only smoke assertion${weakSmokeAssertions === 1 ? "" : "s"} must be replaced with observable domain outcomes.`));
5505
5666
  checks.push(draftSelfCheckItem("Execution profile", plan.executionProfile.confidence === "low" || plan.executionProfile.blockers.length > 0 ? "warning" : "pass", plan.executionProfile.confidence === "low" || plan.executionProfile.blockers.length > 0
5506
5667
  ? "Runner setup, base URL, launch command, or config evidence is incomplete."
5507
5668
  : "Runner setup evidence is strong enough for a local execution attempt."));
@@ -5735,6 +5896,9 @@ function buildMaestroDraft(plan, flow) {
5735
5896
  appendManifestMatchComments(lines, flow, "#");
5736
5897
  for (const step of draftExecutableSteps(flow, "maestro")) {
5737
5898
  const command = maestroCommandForStep(step, selectorQueue);
5899
+ if (maestroCommandProvidesCoverage(command)) {
5900
+ lines.push(`# Step intent: ${step}`);
5901
+ }
5738
5902
  lines.push(...formatMaestroCommand(command));
5739
5903
  }
5740
5904
  appendDomainScenarioComments(lines, flow, "#");
@@ -5763,6 +5927,12 @@ function buildMaestroDraft(plan, flow) {
5763
5927
  lines.push("");
5764
5928
  return lines.join("\n");
5765
5929
  }
5930
+ function maestroCommandProvidesCoverage(command) {
5931
+ if (command.kind === "comment") {
5932
+ return false;
5933
+ }
5934
+ return command.kind !== "assertVisible" || command.value !== quoteYaml(".*");
5935
+ }
5766
5936
  function maestroCommandForStep(step, selectors) {
5767
5937
  if (isGestureStep(step)) {
5768
5938
  return {
@@ -5846,8 +6016,10 @@ function buildPlaywrightDraft(plan, flow, addedDiffText = {}) {
5846
6016
  for (const step of draftExecutableSteps(flow, "playwright")) {
5847
6017
  const manifestCheck = manifestCheckForDraftStep(flow, step);
5848
6018
  const manifestBody = manifestCheck ? playwrightActionForManifestCheck(manifestCheck, step) : undefined;
5849
- const selector = manifestBody ? undefined : takeSelectorForStep(selectorQueue, step);
6019
+ const failureBody = manifestBody ? undefined : playwrightFailureActionForStep(flow, step);
6020
+ const selector = manifestBody || failureBody ? undefined : takeSelectorForStep(selectorQueue, step);
5850
6021
  const body = manifestBody ??
6022
+ failureBody ??
5851
6023
  (selector
5852
6024
  ? playwrightActionForStep(selector, playwrightLocator(selector), step)
5853
6025
  : playwrightFallbackActionForStep(step));
@@ -5857,6 +6029,11 @@ function buildPlaywrightDraft(plan, flow, addedDiffText = {}) {
5857
6029
  appendDomainScenarioComments(lines, flow, " //");
5858
6030
  appendPlaywrightCoverageComments(lines, flow);
5859
6031
  lines.push("});");
6032
+ for (const routedScenario of playwrightRoutedScenarioDrafts(flow)) {
6033
+ lines.push("");
6034
+ lines.push(`// Routed QA scenario: ${routedScenario.scenarioId}`);
6035
+ lines.push(...routedScenario.lines);
6036
+ }
5860
6037
  if (flow.missingTestability.length > 0) {
5861
6038
  lines.push("");
5862
6039
  lines.push("// Testability gaps to address before this spec is stable:");
@@ -5881,6 +6058,73 @@ function buildPlaywrightDraft(plan, flow, addedDiffText = {}) {
5881
6058
  lines.push("");
5882
6059
  return lines.join("\n");
5883
6060
  }
6061
+ function playwrightRoutedScenarioDrafts(flow) {
6062
+ const routeEntrypoint = primaryRouteEntrypoint(flow);
6063
+ if (!routeEntrypoint) {
6064
+ return [];
6065
+ }
6066
+ const routeDraft = buildPlaywrightRouteDraft(routeEntrypoint.value, flow.entrypoints);
6067
+ if (routeDraft.params.length > 0) {
6068
+ return [];
6069
+ }
6070
+ const endpoint = playwrightFailureMockEndpoint(flow);
6071
+ if (!endpoint) {
6072
+ return [];
6073
+ }
6074
+ const drafts = [];
6075
+ for (const scenario of flow.qaScenarios ?? []) {
6076
+ const selection = routeQaScenario(scenario);
6077
+ if (scenario.kind !== "failure" || selection.decision === "review-only") {
6078
+ continue;
6079
+ }
6080
+ const scenarioText = [scenario.title, ...scenario.steps, ...scenario.edgeCases].join(" ");
6081
+ if (!isFailurePathStep(scenarioText)) {
6082
+ continue;
6083
+ }
6084
+ const selectorContext = `${flow.title} ${scenarioText}`;
6085
+ const actionSelector = routedScenarioSelector(flow.selectors, selectorContext, (selector) => !isInputSelector(selector) && selectorCanDriveInteraction(selector));
6086
+ const outcomeSelector = routedScenarioSelector(flow.selectors, selectorContext, (selector) => selectorCanSupportAssertion(selector) && isFailureOutcomeText(selector.value));
6087
+ if (!actionSelector || !outcomeSelector) {
6088
+ continue;
6089
+ }
6090
+ const testName = `${flow.title}: ${scenario.title}`.replaceAll('"', "'");
6091
+ const routePattern = playwrightMockRoutePattern(endpoint);
6092
+ const status = failureResponseStatus(scenarioText);
6093
+ drafts.push({
6094
+ scenarioId: scenario.id,
6095
+ mappedSteps: Math.min(1, scenario.steps.length),
6096
+ mappedAssertions: Math.min(1, scenario.assertions.length),
6097
+ lines: [
6098
+ `test("${testName}", async ({ page }) => {`,
6099
+ ` await page.route("${quoteJs(routePattern)}", async (route) => {`,
6100
+ " await route.fulfill({",
6101
+ ` status: ${status},`,
6102
+ ' contentType: "application/json",',
6103
+ ' body: JSON.stringify({ error: "QAMap simulated failure" }),',
6104
+ " });",
6105
+ " });",
6106
+ ` await page.goto(${routeDraft.expression});`,
6107
+ ` await ${playwrightLocator(actionSelector)}.click();`,
6108
+ ` await expect(${playwrightLocator(outcomeSelector)}).toBeVisible();`,
6109
+ "});",
6110
+ ],
6111
+ });
6112
+ }
6113
+ return drafts;
6114
+ }
6115
+ function routedScenarioSelector(selectors, context, predicate) {
6116
+ const keywords = keywordsForStep(context);
6117
+ return selectors
6118
+ .filter(predicate)
6119
+ .map((selector) => ({
6120
+ selector,
6121
+ score: keywords.filter((keyword) => selector.value.toLowerCase().includes(keyword)).length,
6122
+ }))
6123
+ .filter((candidate) => candidate.score > 0)
6124
+ .sort((left, right) => right.score - left.score ||
6125
+ Number(Boolean(right.selector.addedInDiff)) - Number(Boolean(left.selector.addedInDiff)) ||
6126
+ left.selector.value.localeCompare(right.selector.value))[0]?.selector;
6127
+ }
5884
6128
  function draftExecutableSteps(flow, runner) {
5885
6129
  const executableSteps = flow.steps.filter((step) => !shouldSkipDraftStep(step, flow, runner));
5886
6130
  return executableSteps.length > 0 ? executableSteps : flow.steps;
@@ -6833,19 +7077,24 @@ function formatDraftActionKindSummary(summary) {
6833
7077
  .map((item) => `${item.kind} ${item.total} (${item.required} required, ${item.recommended} recommended)`)
6834
7078
  .join(", ");
6835
7079
  }
6836
- function buildDraftNextSteps(plan, runner) {
7080
+ function buildDraftNextSteps(plan, runner, files) {
6837
7081
  const steps = [];
7082
+ const hasTodos = files.some((file) => (file.todoCount ?? 0) > 0);
6838
7083
  if (runner === "maestro") {
6839
7084
  steps.push(plan.executionProfile.appId
6840
7085
  ? `Use app id ${plan.executionProfile.appId} or export APP_ID before running Maestro.`
6841
7086
  : "Replace ${APP_ID} or export APP_ID before running Maestro.");
6842
- steps.push("Replace TODO text selectors with visible copy, testID, or accessibilityLabel selectors.");
7087
+ if (hasTodos) {
7088
+ steps.push("Replace TODO text selectors with visible copy, testID, or accessibilityLabel selectors.");
7089
+ }
6843
7090
  steps.push(plan.executionProfile.startCommand
6844
7091
  ? `Launch the app with \`${plan.executionProfile.startCommand}\`, then run \`${plan.executionProfile.testCommand ?? "maestro test .maestro"}\`.`
6845
7092
  : `Run the app with the launch command that matches your simulator or device, then run \`${plan.executionProfile.testCommand ?? "maestro test .maestro"}\`.`);
6846
7093
  }
6847
7094
  else if (runner === "playwright") {
6848
- steps.push("Replace TODO locators with role, text, or data-testid locators from the app.");
7095
+ if (hasTodos) {
7096
+ steps.push("Replace TODO locators with role, text, or data-testid locators from the app.");
7097
+ }
6849
7098
  if (plan.executionProfile.blockers.some((blocker) => /No Playwright config/i.test(blocker))) {
6850
7099
  steps.push(playwrightConfigGuidance(plan.executionProfile));
6851
7100
  }
@@ -7124,6 +7373,53 @@ function playwrightActionForStep(selector, locator, step) {
7124
7373
  body.push(`await ${locator}.click();`);
7125
7374
  return body;
7126
7375
  }
7376
+ function playwrightFailureActionForStep(flow, step) {
7377
+ if (!isFailurePathStep(step)) {
7378
+ return undefined;
7379
+ }
7380
+ const endpoint = playwrightFailureMockEndpoint(flow);
7381
+ const actionSelector = flow.selectors.find((selector) => !isInputSelector(selector) && selectorCanDriveInteraction(selector));
7382
+ const outcomeSelector = flow.selectors.find((selector) => selectorCanSupportAssertion(selector) && isFailureOutcomeText(selector.value));
7383
+ if (!endpoint || !actionSelector || !outcomeSelector) {
7384
+ return undefined;
7385
+ }
7386
+ const routePattern = playwrightMockRoutePattern(endpoint);
7387
+ const status = failureResponseStatus(step);
7388
+ return [
7389
+ `// Step intent: ${step}`,
7390
+ `await page.unroute("${quoteJs(routePattern)}");`,
7391
+ `await page.route("${quoteJs(routePattern)}", async (route) => {`,
7392
+ " await route.fulfill({",
7393
+ ` status: ${status},`,
7394
+ ' contentType: "application/json",',
7395
+ ' body: JSON.stringify({ error: "QAMap simulated failure" }),',
7396
+ " });",
7397
+ "});",
7398
+ `await ${playwrightLocator(actionSelector)}.click();`,
7399
+ `await expect(${playwrightLocator(outcomeSelector)}).toBeVisible();`,
7400
+ ];
7401
+ }
7402
+ function playwrightFailureMockEndpoint(flow) {
7403
+ const observedEndpoints = observedEndpointsForFlow(flow);
7404
+ return flow.fixtureReadiness.apiEndpoints.find((endpoint) => !endpointMatchesAny(endpoint, observedEndpoints));
7405
+ }
7406
+ function isFailurePathStep(step) {
7407
+ return /\b(?:blocked|declined|denied|empty|error|failed|failure|invalid|rejected|timeout|unauthori[sz]ed)\b/i.test(step) ||
7408
+ /(?:오류|실패|거절|권한|잘못|할 수 없)/.test(step);
7409
+ }
7410
+ function isFailureOutcomeText(value) {
7411
+ return /\b(?:cannot|could not|declined|denied|error|failed|failure|invalid|rejected|try again|unauthori[sz]ed|unavailable)\b/i.test(value) ||
7412
+ /(?:오류|실패|거절|권한|잘못|할 수 없)/.test(value);
7413
+ }
7414
+ function failureResponseStatus(step) {
7415
+ if (/\b(?:denied|unauthori[sz]ed)\b/i.test(step) || /권한/.test(step)) {
7416
+ return 401;
7417
+ }
7418
+ if (/\b(?:invalid|validation)\b/i.test(step) || /잘못/.test(step)) {
7419
+ return 422;
7420
+ }
7421
+ return 500;
7422
+ }
7127
7423
  function manifestCheckForDraftStep(flow, step) {
7128
7424
  const checks = flow.manifestCheckMatches ?? [];
7129
7425
  const stepKey = normalizeManifestStepKey(step);