@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/CHANGELOG.md +36 -0
- package/README.md +10 -1
- package/dist/e2e.d.ts +25 -0
- package/dist/e2e.js +315 -19
- package/dist/e2e.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/qa.d.ts +2 -1
- package/dist/qa.js +68 -10
- package/dist/qa.js.map +1 -1
- package/dist/scenario-routing.d.ts +11 -0
- package/dist/scenario-routing.js +72 -0
- package/dist/scenario-routing.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/agent-format.md +10 -8
- package/docs/architecture.md +23 -2
- package/docs/assets/qamap-cover.png +0 -0
- package/docs/assets/qamap-cover.svg +56 -0
- package/docs/benchmarking.md +17 -1
- package/docs/commands.md +4 -0
- package/docs/e2e-output-examples.md +32 -19
- package/docs/release-validation.md +34 -0
- package/docs/releasing.md +2 -1
- package/docs/roadmap.md +10 -1
- package/package.json +1 -1
- package/schema/qamap-agent.schema.json +51 -1
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
|
|
226
|
-
const
|
|
227
|
-
const
|
|
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
|
|
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("
|
|
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,
|
|
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
|
-
|
|
5450
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|