@sun-asterisk/sungen 3.2.3 → 3.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/cli/commands/feedback.d.ts +11 -0
  2. package/dist/cli/commands/feedback.d.ts.map +1 -1
  3. package/dist/cli/commands/feedback.js +154 -57
  4. package/dist/cli/commands/feedback.js.map +1 -1
  5. package/dist/cli/commands/login.d.ts +19 -0
  6. package/dist/cli/commands/login.d.ts.map +1 -0
  7. package/dist/cli/commands/login.js +76 -0
  8. package/dist/cli/commands/login.js.map +1 -0
  9. package/dist/cli/commands/qa-feedback.d.ts +14 -0
  10. package/dist/cli/commands/qa-feedback.d.ts.map +1 -0
  11. package/dist/cli/commands/qa-feedback.js +83 -0
  12. package/dist/cli/commands/qa-feedback.js.map +1 -0
  13. package/dist/cli/index.js +4 -0
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  16. package/dist/dashboard/snapshot-builder.js +108 -33
  17. package/dist/dashboard/snapshot-builder.js.map +1 -1
  18. package/dist/dashboard/templates/index.html +23 -23
  19. package/dist/dashboard/types.d.ts +4 -0
  20. package/dist/dashboard/types.d.ts.map +1 -1
  21. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  22. package/dist/exporters/csv-exporter.js +11 -4
  23. package/dist/exporters/csv-exporter.js.map +1 -1
  24. package/dist/exporters/feature-parser.d.ts +4 -0
  25. package/dist/exporters/feature-parser.d.ts.map +1 -1
  26. package/dist/exporters/feature-parser.js +7 -2
  27. package/dist/exporters/feature-parser.js.map +1 -1
  28. package/dist/exporters/json-exporter.d.ts.map +1 -1
  29. package/dist/exporters/json-exporter.js +11 -3
  30. package/dist/exporters/json-exporter.js.map +1 -1
  31. package/dist/exporters/playwright-report-parser.d.ts +21 -2
  32. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  33. package/dist/exporters/playwright-report-parser.js +52 -12
  34. package/dist/exporters/playwright-report-parser.js.map +1 -1
  35. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  36. package/dist/exporters/scenario-merger.js +50 -2
  37. package/dist/exporters/scenario-merger.js.map +1 -1
  38. package/dist/exporters/spec-parser.js +1 -1
  39. package/dist/exporters/spec-parser.js.map +1 -1
  40. package/dist/exporters/test-data-resolver.d.ts +7 -0
  41. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  42. package/dist/exporters/test-data-resolver.js +13 -0
  43. package/dist/exporters/test-data-resolver.js.map +1 -1
  44. package/dist/exporters/types.d.ts +6 -0
  45. package/dist/exporters/types.d.ts.map +1 -1
  46. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  47. package/dist/exporters/xlsx-exporter.js +131 -42
  48. package/dist/exporters/xlsx-exporter.js.map +1 -1
  49. package/dist/generators/gherkin-parser/index.d.ts +15 -0
  50. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  51. package/dist/generators/gherkin-parser/index.js +33 -1
  52. package/dist/generators/gherkin-parser/index.js.map +1 -1
  53. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
  54. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
  55. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
  56. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
  57. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
  58. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
  59. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
  60. package/dist/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
  61. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
  62. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
  63. package/dist/harness/script-check.d.ts.map +1 -1
  64. package/dist/harness/script-check.js +10 -5
  65. package/dist/harness/script-check.js.map +1 -1
  66. package/dist/orchestrator/ai-skills/writer.d.ts.map +1 -1
  67. package/dist/orchestrator/ai-skills/writer.js +46 -4
  68. package/dist/orchestrator/ai-skills/writer.js.map +1 -1
  69. package/dist/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
  70. package/dist/orchestrator/templates/ai-src/commands/create-test.md +7 -0
  71. package/dist/orchestrator/templates/ai-src/commands/feedback.md +6 -6
  72. package/dist/orchestrator/templates/ai-src/commands/locale.md +26 -4
  73. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
  74. package/dist/orchestrator/templates/wdio.conf.ts +39 -3
  75. package/dist/tools/sungen-api/edit-in-editor.d.ts +15 -0
  76. package/dist/tools/sungen-api/edit-in-editor.d.ts.map +1 -0
  77. package/dist/tools/sungen-api/edit-in-editor.js +85 -0
  78. package/dist/tools/sungen-api/edit-in-editor.js.map +1 -0
  79. package/dist/tools/sungen-api/feedback-client.d.ts +24 -0
  80. package/dist/tools/sungen-api/feedback-client.d.ts.map +1 -0
  81. package/dist/tools/sungen-api/feedback-client.js +53 -0
  82. package/dist/tools/sungen-api/feedback-client.js.map +1 -0
  83. package/dist/tools/sungen-api/read-stdin.d.ts +6 -0
  84. package/dist/tools/sungen-api/read-stdin.d.ts.map +1 -0
  85. package/dist/tools/sungen-api/read-stdin.js +19 -0
  86. package/dist/tools/sungen-api/read-stdin.js.map +1 -0
  87. package/dist/tools/sungen-auth/api-base-url.d.ts +14 -0
  88. package/dist/tools/sungen-auth/api-base-url.d.ts.map +1 -0
  89. package/dist/tools/sungen-auth/api-base-url.js +28 -0
  90. package/dist/tools/sungen-auth/api-base-url.js.map +1 -0
  91. package/dist/tools/sungen-auth/credentials-store.d.ts +19 -0
  92. package/dist/tools/sungen-auth/credentials-store.d.ts.map +1 -0
  93. package/dist/tools/sungen-auth/credentials-store.js +94 -0
  94. package/dist/tools/sungen-auth/credentials-store.js.map +1 -0
  95. package/dist/tools/sungen-auth/login-flow.d.ts +33 -0
  96. package/dist/tools/sungen-auth/login-flow.d.ts.map +1 -0
  97. package/dist/tools/sungen-auth/login-flow.js +107 -0
  98. package/dist/tools/sungen-auth/login-flow.js.map +1 -0
  99. package/dist/tools/sungen-auth/loopback-server.d.ts +20 -0
  100. package/dist/tools/sungen-auth/loopback-server.d.ts.map +1 -0
  101. package/dist/tools/sungen-auth/loopback-server.js +132 -0
  102. package/dist/tools/sungen-auth/loopback-server.js.map +1 -0
  103. package/dist/tools/sungen-auth/open-browser.d.ts +10 -0
  104. package/dist/tools/sungen-auth/open-browser.d.ts.map +1 -0
  105. package/dist/tools/sungen-auth/open-browser.js +43 -0
  106. package/dist/tools/sungen-auth/open-browser.js.map +1 -0
  107. package/dist/tools/sungen-auth/pkce.d.ts +13 -0
  108. package/dist/tools/sungen-auth/pkce.d.ts.map +1 -0
  109. package/dist/tools/sungen-auth/pkce.js +27 -0
  110. package/dist/tools/sungen-auth/pkce.js.map +1 -0
  111. package/dist/tools/sungen-auth/sungen-auth-types.d.ts +41 -0
  112. package/dist/tools/sungen-auth/sungen-auth-types.d.ts.map +1 -0
  113. package/dist/tools/sungen-auth/sungen-auth-types.js +7 -0
  114. package/dist/tools/sungen-auth/sungen-auth-types.js.map +1 -0
  115. package/package.json +3 -3
  116. package/src/cli/commands/feedback.ts +123 -51
  117. package/src/cli/commands/login.ts +75 -0
  118. package/src/cli/commands/qa-feedback.ts +77 -0
  119. package/src/cli/index.ts +4 -0
  120. package/src/dashboard/snapshot-builder.ts +100 -27
  121. package/src/dashboard/templates/index.html +23 -23
  122. package/src/dashboard/types.ts +5 -1
  123. package/src/exporters/csv-exporter.ts +10 -4
  124. package/src/exporters/feature-parser.ts +7 -2
  125. package/src/exporters/json-exporter.ts +11 -3
  126. package/src/exporters/playwright-report-parser.ts +50 -11
  127. package/src/exporters/scenario-merger.ts +49 -3
  128. package/src/exporters/spec-parser.ts +1 -1
  129. package/src/exporters/test-data-resolver.ts +13 -0
  130. package/src/exporters/types.ts +6 -0
  131. package/src/exporters/xlsx-exporter.ts +155 -49
  132. package/src/generators/gherkin-parser/index.ts +44 -1
  133. package/src/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
  134. package/src/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
  135. package/src/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
  136. package/src/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
  137. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
  138. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
  139. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
  140. package/src/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
  141. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
  142. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
  143. package/src/harness/script-check.ts +10 -5
  144. package/src/orchestrator/ai-skills/writer.ts +39 -4
  145. package/src/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
  146. package/src/orchestrator/templates/ai-src/commands/create-test.md +7 -0
  147. package/src/orchestrator/templates/ai-src/commands/feedback.md +6 -6
  148. package/src/orchestrator/templates/ai-src/commands/locale.md +26 -4
  149. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
  150. package/src/orchestrator/templates/wdio.conf.ts +39 -3
  151. package/src/tools/sungen-api/edit-in-editor.ts +48 -0
  152. package/src/tools/sungen-api/feedback-client.ts +62 -0
  153. package/src/tools/sungen-api/read-stdin.ts +15 -0
  154. package/src/tools/sungen-auth/api-base-url.ts +23 -0
  155. package/src/tools/sungen-auth/credentials-store.ts +57 -0
  156. package/src/tools/sungen-auth/login-flow.ts +135 -0
  157. package/src/tools/sungen-auth/loopback-server.ts +113 -0
  158. package/src/tools/sungen-auth/open-browser.ts +42 -0
  159. package/src/tools/sungen-auth/pkce.ts +27 -0
  160. package/src/tools/sungen-auth/sungen-auth-types.ts +45 -0
@@ -41,12 +41,14 @@ export interface AggregateSummary {
41
41
  export interface ScreenSnapshot {
42
42
  name: string; // "kudos" or "flow/checkout" — the screen DIRECTORY name
43
43
  isFlow: boolean;
44
+ platform?: 'web' | 'android' | 'ios' | 'mobile'; // 'mobile' = @platform:mobile screen (per-OS features below)
44
45
  featureName: string; // name of the primary (first) feature; kept for backwards compat
45
46
  featurePath?: string; // entry-point path of the primary feature
46
47
  specLink?: string; // relative path to requirements/spec.md
47
48
  summary: ScreenSummaryStats; // rollup across all features
48
49
  scenarios: ScenarioSnapshot[]; // flat list across all features (each carries its `featureBaseName`)
49
50
  features?: FeatureSnapshot[]; // present when a screen has 1+ .feature files; UI groups by this
51
+ executedPlatforms?: ('android' | 'ios')[]; // OSes that produced a result file this run (mobile only)
50
52
  }
51
53
 
52
54
  /**
@@ -67,7 +69,9 @@ export interface FeatureSnapshot {
67
69
  specLink?: string; // relative path to requirements/spec.md, when present
68
70
  summary: ScreenSummaryStats; // BASE locale rollup — back-compat for non-locale consumers
69
71
  scenarios: ScenarioSnapshot[]; // BASE locale scenarios
70
- locales?: LocaleSnapshot[]; // present when 2+ locales detected (base + at least one overlay)
72
+ locales?: LocaleSnapshot[]; // locale overlays WITHIN this entry (base + at least one overlay)
73
+ platform?: 'android' | 'ios'; // one OS run of a @platform:mobile feature (separate entry per OS)
74
+ executedPlatforms?: ('android' | 'ios')[]; // OSes that produced a result file this run (mobile only)
71
75
  }
72
76
 
73
77
  /**
@@ -16,7 +16,7 @@ import {
16
16
  splitVpAndName,
17
17
  } from './feature-parser';
18
18
  import { formatFeatureSteps, formatPrecondition, keepActor, stripActor } from './step-formatter';
19
- import { formatTestData } from './test-data-resolver';
19
+ import { formatTestData, substituteTestDataVars } from './test-data-resolver';
20
20
  import {
21
21
  formatExecutedDate,
22
22
  formatNote,
@@ -83,10 +83,16 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
83
83
  // Pre-condition is auth-only. Steps section = Background + @extend base
84
84
  // (entire) + own pre-Then. Expected = own's Then-onwards only.
85
85
  const precondition = formatPrecondition(authRole);
86
- const localize = (text: string): string =>
87
- input.selectorKeyMap
86
+ const localize = (text: string): string => {
87
+ // 1. resolve [Reference] selector keys → their label/value; 2. resolve {{var}} test-data
88
+ // placeholders → concrete values, so Steps / Expected results show what QA actually verifies
89
+ // (e.g. `text contains {{app_version}}` → `text contains Version 1.0.0`) instead of the raw
90
+ // template. An unknown {{var}} is left literal.
91
+ const withKeys = input.selectorKeyMap
88
92
  ? substituteSelectorKeysInStep(text, input.selectorKeyMap, input.testData)
89
93
  : text;
94
+ return substituteTestDataVars(withKeys, input.testData);
95
+ };
90
96
  const formatStep = (s: { text: string; bucket: 'given' | 'when' | 'then' }) =>
91
97
  localize(s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text));
92
98
  const stepLines = m.resolvedSteps.map(formatStep).filter(Boolean);
@@ -127,7 +133,7 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
127
133
  } else {
128
134
  testResult = statusToTestResult(result.status);
129
135
  executedDate = formatExecutedDate(result.startTime);
130
- note = formatNote(result);
136
+ note = formatNote(result, expectedResults);
131
137
  environment = formatEnv(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
132
138
  executor = input.env.executor;
133
139
  }
@@ -90,6 +90,7 @@ export function parseFeatureMetadata(featureFilePath: string): FeatureMetadata {
90
90
  rawWhenSteps: when,
91
91
  rawThenSteps: then,
92
92
  orderedSteps: ordered,
93
+ comments: sc.comments,
93
94
  };
94
95
  });
95
96
 
@@ -132,9 +133,13 @@ export function extractPriority(tags: string[]): string {
132
133
 
133
134
  /**
134
135
  * Extract testcase type from scenario tags.
136
+ * Matches the bare `@manual` AND the reason-coded form `@manual:M1`..`@manual:M9`
137
+ * (the canonical way to tag manual scenarios). Without the `startsWith` check a
138
+ * `@manual:M1` scenario was misclassified as `Auto` → rendered "Not compiled"/"N/A"
139
+ * in the report instead of "Manual"/"Pending".
135
140
  */
136
141
  export function extractTestcaseType(tags: string[]): 'Auto' | 'Manual' {
137
- return tags.includes('@manual') ? 'Manual' : 'Auto';
142
+ return tags.some((t) => t === '@manual' || t.startsWith('@manual:')) ? 'Manual' : 'Auto';
138
143
  }
139
144
 
140
145
  /**
@@ -153,7 +158,7 @@ export function extractAuthRole(tags: string[]): string | null {
153
158
  * { vpId: "VP-UI-001", category1: "Modal displays all required fields" }
154
159
  */
155
160
  export function splitVpAndName(scenarioName: string): { vpId?: string; category1: string } {
156
- const match = scenarioName.match(/^(VP-[A-Z]+-\d+[a-zA-Z]?)\s+(.+)$/);
161
+ const match = scenarioName.match(/^(VP-[A-Z][A-Z0-9]*-\d+[a-zA-Z]?)\s+(.+)$/);
157
162
  if (match) {
158
163
  return { vpId: match[1], category1: match[2] };
159
164
  }
@@ -111,9 +111,17 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
111
111
  status = statusToTestResult(result.status) as ScenarioSnapshot['status'];
112
112
  executedDate = formatExecutedDate(result.startTime);
113
113
  if (result.error) {
114
- // Strip a leading "Error:" so consumers (dashboard / CSV `Note`)
115
- // can prepend their own label without ending up with `Error: Error:`.
116
- errorMessage = String(result.error).split('\n')[0].trim().replace(/^Error:\s*/i, '');
114
+ // Keep the FULL assertion message on one line. Do NOT use split('\n')[0]: a WDIO
115
+ // selector value can itself contain a newline (a Flutter composite accessibility-id
116
+ // like `~Share with Friends\nSpread the word`), which would truncate the message
117
+ // mid-selector. Drop the stack trace (frames start with "at "), collapse the remaining
118
+ // newlines (incl. the Expected/Received lines) to spaces, and strip a leading "Error:"
119
+ // so consumers (dashboard / CSV `Note`) can prepend their own label.
120
+ errorMessage = String(result.error)
121
+ .split(/\n\s*at\s/)[0]
122
+ .replace(/\s*\n\s*/g, ' ')
123
+ .trim()
124
+ .replace(/^Error:\s*/i, '');
117
125
  }
118
126
  if (result.tracePath) tracePath = result.tracePath;
119
127
  testEnvironment = formatTestEnvironment(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
@@ -192,22 +192,61 @@ export function formatExecutedDate(startTime: string | undefined): string {
192
192
  return `${dd}/${mm}/${yyyy}`;
193
193
  }
194
194
 
195
+ const NOTE_FIELD_MAX = 300;
196
+ const clip = (s: string): string => {
197
+ const t = s.replace(/\s*\n\s*/g, ' ').trim();
198
+ return t.length > NOTE_FIELD_MAX ? t.slice(0, NOTE_FIELD_MAX - 1) + '…' : t;
199
+ };
200
+
201
+ /**
202
+ * Split an assertion failure message into its Expected + Received values, so QA can compare.
203
+ * Both frameworks embed them in the error message: Playwright — `Expected string: "…"` /
204
+ * `Received string: "…"` (also `Expected pattern/substring/value`, `Received value`); expect-
205
+ * webdriverio — `Expected: …` / `Received: …`. `(.+)` (no /s flag) captures to end-of-line, so a
206
+ * multi-line stack after the value is not swallowed. Undefined when the message isn't an assertion
207
+ * (e.g. element-not-found / timeout) — then there is no runtime Expected to show.
208
+ */
209
+ export function parseExpectedReceived(raw: string): { expected?: string; received?: string } {
210
+ const grab = (re: RegExp): string | undefined => { const m = raw.match(re); return m ? clip(m[1]) : undefined; };
211
+ return {
212
+ expected: grab(/\bExpected(?:\s+[\w-]+)?:\s*(.+)/i),
213
+ received: grab(/\bReceived(?:\s+[\w-]+)?:\s*(.+)/i),
214
+ };
215
+ }
216
+
195
217
  /**
196
- * Format error message for Note column. Truncate long stacks, include trace path.
218
+ * Format the Note column for a FAILED test case (returns '' for any non-failed status — so only
219
+ * `Test Result = Failed` rows get a Note). Emits the two fields QA compares:
220
+ * • Expected result: the asserted value parsed from the error, or — when the failure isn't an
221
+ * assertion (timeout / element-not-found, which carry no Expected/Received) — the scenario's
222
+ * expected (`expectedFallback`, i.e. the "Expected results" column / Then steps), so QA always
223
+ * sees what SHOULD have happened next to the actual.
224
+ * • Actual result: the Received value, or the raw error.
225
+ * Plus the trace path.
197
226
  */
198
- export function formatNote(result: PlaywrightResult | undefined): string {
227
+ export function formatNote(result: PlaywrightResult | undefined, expectedFallback = ''): string {
199
228
  if (!result) return '';
200
229
  if (result.status !== 'failed' && result.status !== 'timedOut') return '';
201
- // Some Playwright error messages already start with `Error:` — strip it
202
- // so we don't end up rendering `Error: Error: …` once we prepend the label.
203
- const firstLine = (result.error || '').split('\n')[0].trim().replace(/^Error:\s*/i, '');
204
- const truncated = firstLine.length > 200 ? firstLine.substring(0, 197) + '…' : firstLine;
230
+ // Full assertion message with stack frames dropped (frames start with "at ") + a leading `Error:`
231
+ // stripped. Keep newlines here so parseExpectedReceived can delimit the Expected/Received lines.
232
+ const raw = (result.error || '').split(/\n\s*at\s/)[0].replace(/^Error:\s*/i, '').trim();
233
+ const { expected, received } = parseExpectedReceived(raw);
234
+ const actual = received || clip(raw); // received value, else the raw error
235
+
236
+ // Newline-separated bullets so each field lands on its own row when the cell wraps — matches the
237
+ // Test Data column's `• key: value` formatting. Excel/CSV honour `\n` inside quoted cells; the
238
+ // dashboard modal renders with whitespace-pre-wrap.
205
239
  const parts: string[] = [];
206
- if (truncated) parts.push(`• Error: ${truncated}`);
240
+ // Expected result: a parsed assertion value stays inline; the scenario expected (Then steps) can be
241
+ // several numbered criteria — put each on its own indented line so the cell reads as a list.
242
+ if (expected) {
243
+ parts.push(`• Expected result: ${expected}`);
244
+ } else {
245
+ const lines = expectedFallback.split(/\r?\n/).map((l) => clip(l)).filter(Boolean);
246
+ if (lines.length === 1) parts.push(`• Expected result: ${lines[0]}`);
247
+ else if (lines.length > 1) parts.push(`• Expected result:\n${lines.map((l) => ` ${l}`).join('\n')}`);
248
+ }
249
+ if (actual) parts.push(`• Actual result: ${actual}`);
207
250
  if (result.tracePath) parts.push(`• Trace: ${result.tracePath}`);
208
- // Newline-separated bullets so each field lands on its own row when the
209
- // cell wraps — matches the Test Data column's `• key: value` formatting.
210
- // Excel/CSV honour `\n` inside quoted cells; the dashboard modal already
211
- // renders this with whitespace-pre-wrap.
212
251
  return parts.join('\n');
213
252
  }
@@ -9,7 +9,43 @@
9
9
  */
10
10
 
11
11
  import { FeatureMetadata, OrderedStep, ScenarioMetadata, SpecFileData, SpecTest } from './types';
12
- import { isStepsBaseScenario, isSampleScaffoldScenario } from './feature-parser';
12
+ import { isStepsBaseScenario, isSampleScaffoldScenario, extractTestcaseType } from './feature-parser';
13
+
14
+ /**
15
+ * Parse a @manual scenario's `# Tester verifies:` comment block into procedure vs
16
+ * expected. Each item is a numbered line, optionally prefixed with a label:
17
+ * 1. Setup: … 2. Action: … → procedure (Steps column)
18
+ * 3. Observable: … 4. Oracle: … → expected (Expected Results column)
19
+ * A label matching observ|oracle|expect|result|then|assert → Expected; anything else
20
+ * (Setup/Action/Precondition/unlabelled) → Steps. A non-numbered line following a
21
+ * numbered item is a continuation (e.g. a wrapped URL) and is appended to it. Pre-amble
22
+ * lines (the `MANUAL:` rationale, the `Tester verifies:` header, separators) are ignored.
23
+ */
24
+ function parseManualComments(comments: string[]): { steps: OrderedStep[]; expected: OrderedStep[] } {
25
+ const steps: OrderedStep[] = [];
26
+ const expected: OrderedStep[] = [];
27
+ let last: OrderedStep | null = null;
28
+ const isExpectedLabel = (l: string) => /observ|oracle|expect|result|then|assert/i.test(l);
29
+ for (const raw of comments) {
30
+ const line = raw.trim();
31
+ if (!line) continue;
32
+ // Section divider — all divider chars (`-----`) OR a titled rule (`--- Foo ---`).
33
+ // A run of 2+ leading divider chars is never a step or a continuation.
34
+ if (/^[-=*_]{2,}/.test(line)) { last = null; continue; }
35
+ const m = line.match(/^\d+[.)]\s*(?:([A-Za-z][A-Za-z /]*?):\s*)?(.+)$/);
36
+ if (m) {
37
+ const label = (m[1] || '').trim();
38
+ const text = label ? `${label}: ${m[2].trim()}` : m[2].trim();
39
+ const step: OrderedStep = { text, bucket: isExpectedLabel(label) ? 'then' : 'when' };
40
+ (step.bucket === 'then' ? expected : steps).push(step);
41
+ last = step;
42
+ } else if (last) {
43
+ last.text += ' ' + line; // continuation of the previous numbered item
44
+ }
45
+ // else: pre-amble before any numbered item (rationale / header) → skip
46
+ }
47
+ return { steps, expected };
48
+ }
13
49
 
14
50
  export interface MergedScenario {
15
51
  /** Scenario metadata from .feature */
@@ -76,6 +112,15 @@ export function mergeFeatureAndSpec(
76
112
  const ownPreThen = firstThenIdx >= 0 ? ownOrdered.slice(0, firstThenIdx) : ownOrdered;
77
113
  const ownPostThen = firstThenIdx >= 0 ? ownOrdered.slice(firstThenIdx) : [];
78
114
 
115
+ // A @manual scenario carries no Gherkin steps — its tester procedure lives in the
116
+ // `# Tester verifies:` comment block. Parse it into Steps (Setup/Action) + Expected
117
+ // (Observable/Oracle) so the delivery report shows the real manual procedure, not
118
+ // just the Background nav. No-op for automated scenarios (comments stay notes).
119
+ const manualParsed =
120
+ extractTestcaseType(scenario.tags) === 'Manual' && scenario.comments?.length
121
+ ? parseManualComments(scenario.comments)
122
+ : { steps: [] as OrderedStep[], expected: [] as OrderedStep[] };
123
+
79
124
  result.push({
80
125
  feature: scenario,
81
126
  spec: specTest,
@@ -98,8 +143,9 @@ export function mergeFeatureAndSpec(
98
143
  ...feature.backgroundOrderedSteps,
99
144
  ...(base?.orderedSteps ?? []),
100
145
  ...ownPreThen,
146
+ ...manualParsed.steps,
101
147
  ],
102
- resolvedExpected: ownPostThen,
148
+ resolvedExpected: [...ownPostThen, ...manualParsed.expected],
103
149
  });
104
150
  }
105
151
 
@@ -116,7 +162,7 @@ function findMatchingSpecTest(scenario: ScenarioMetadata, tests: SpecTest[]): Sp
116
162
  if (exact) return exact;
117
163
 
118
164
  // Fallback: match by VP-ID prefix (ignore trailing text differences)
119
- const vpMatch = scenario.name.match(/^(VP-[A-Z]+-\d+[a-zA-Z]?)/);
165
+ const vpMatch = scenario.name.match(/^(VP-[A-Z][A-Z0-9]*-\d+[a-zA-Z]?)/);
120
166
  if (vpMatch) {
121
167
  const vpId = vpMatch[1];
122
168
  const byVp = tests.find((t) => t.vpId === vpId);
@@ -146,7 +146,7 @@ function classifyBlock(block: CodeBlock): 'precondition' | 'step' | 'expectation
146
146
  * Try to extract VP ID (e.g., VP-UI-001) from the start of a scenario title.
147
147
  */
148
148
  function extractVpId(title: string): string | undefined {
149
- const match = title.match(/^(VP-[A-Z]+-\d+[a-zA-Z]?)\b/);
149
+ const match = title.match(/^(VP-[A-Z][A-Z0-9]*-\d+[a-zA-Z]?)\b/);
150
150
  return match ? match[1] : undefined;
151
151
  }
152
152
 
@@ -133,6 +133,19 @@ function truncate(s: string, max: number): string {
133
133
  return s.substring(0, max - 1) + '…';
134
134
  }
135
135
 
136
+ /**
137
+ * Resolve `{{var}}` test-data placeholders in a step / expected-result line to their concrete values,
138
+ * so the delivery report shows what QA actually verifies (e.g. `text contains {{app_version}}` →
139
+ * `text contains Version 1.0.0`). Dotted keys (`{{a.b}}`) are supported. A key absent from the
140
+ * test-data map (or resolving to empty) is left as the literal `{{var}}` — never blanked.
141
+ */
142
+ export function substituteTestDataVars(text: string, testData: Record<string, string>): string {
143
+ return text.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (m, key: string) => {
144
+ const v = testData[key];
145
+ return v === undefined || v === null || v === '' ? m : String(v);
146
+ });
147
+ }
148
+
136
149
  /**
137
150
  * Resolve the test-data file path for a given screen.
138
151
  */
@@ -88,6 +88,12 @@ export interface ScenarioMetadata {
88
88
  * Use this when ordering matters — e.g. mid-scenario `Given` after `When`.
89
89
  */
90
90
  orderedSteps: OrderedStep[];
91
+ /**
92
+ * Comment lines inside the scenario body (leading `# ` stripped). For a @manual
93
+ * scenario the tester procedure lives here (as `# Tester verifies:` comments), not
94
+ * as Gherkin steps — the merger parses these into Steps/Expected for the report.
95
+ */
96
+ comments?: string[];
91
97
  }
92
98
 
93
99
  /**
@@ -30,6 +30,15 @@ const META_FILL = 'FFDCE6F1'; // soft blue for meta rows
30
30
  const CATEGORY_FILL = 'FFD9D2E9'; // same lavender for group dividers (Accessing / GUI / Function)
31
31
  const BORDER_COLOR = 'FFBFBFBF';
32
32
 
33
+ // Shared masthead + table styling (BM-2-901-13 header band, summary band, Overview table).
34
+ const TIMES = 'Times New Roman';
35
+ const ARIAL_FONT = 'Arial';
36
+ const BLACK = { argb: 'FF000000' };
37
+ const WHITE = { argb: 'FFFFFFFF' };
38
+ const LAVENDER = { argb: 'FFD9D2E9' };
39
+ const thinBlack = { style: 'thin' as const, color: BLACK };
40
+ const allBordersBlack = { top: thinBlack, left: thinBlack, bottom: thinBlack, right: thinBlack };
41
+
33
42
  type AnyCell = ExcelJS.Cell;
34
43
  type AnyRow = ExcelJS.Row;
35
44
 
@@ -96,6 +105,9 @@ export function renderXlsxMultiSheet(sheets: LocaleSheet[]): ExcelJS.Workbook {
96
105
  const wb = new ExcelJS.Workbook();
97
106
  wb.creator = 'sungen delivery';
98
107
  wb.created = new Date();
108
+ // Overview first — a per-sheet roll-up (Passed/Failed/Pending/N/A/Remain/Total)
109
+ // plus a grand-total row, so QA sees the whole picture before the detail sheets.
110
+ addOverviewSheet(wb, sheets);
99
111
  for (const s of sheets) {
100
112
  addTestcaseSheet(wb, s.sheetName, s.summary, s.rows, s.specLink);
101
113
  }
@@ -103,57 +115,23 @@ export function renderXlsxMultiSheet(sheets: LocaleSheet[]): ExcelJS.Workbook {
103
115
  }
104
116
 
105
117
  /**
106
- * Append one BM-2-901-13 testcase sheet to the given workbook. Sheet content
107
- * (layout, summary band, data rows, auto-filter, protection) is identical to
108
- * the single-sheet `renderXlsx` output only the sheet name differs.
118
+ * Render the shared BM-2-901-13 masthead (rows 1-4) onto a worksheet:
119
+ * Sun* logo (A1:C3), TESTCASE title (D1:F3), No/Version/Issue-Date block
120
+ * (G1:H3), company line (A4:F4), ISO line (G4:H4). Every sheet in a delivery
121
+ * workbook — Overview, Auto, Manual — carries this same header band.
109
122
  */
110
- function addTestcaseSheet(
123
+ function renderReportHeaderBand(
111
124
  wb: ExcelJS.Workbook,
112
- sheetName: string,
113
- summary: ScreenSummary,
114
- rows: TestCaseRow[],
115
- specLink: string,
125
+ ws: ExcelJS.Worksheet,
126
+ titleLabel: string,
116
127
  ): void {
117
- const ws = wb.addWorksheet(sheetName, {
118
- views: [{ state: 'frozen', ySplit: 0, xSplit: 0 }],
119
- });
120
-
121
128
  const issueDate = (() => {
122
129
  const d = new Date();
123
130
  return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
124
131
  })();
125
132
 
126
- // -- Column widths matching template_report.xlsx (Sample sheet) --
127
- ws.columns = [
128
- { width: 20 }, // A — TC ID (wider to fit long flow IDs like FLOW-KUDO-…)
129
- { width: 12.5 }, // B — Screen/Function
130
- { width: 15.38 }, // C — Big item
131
- { width: 16.25 }, // D — Medium item
132
- { width: 17 }, // E — Test Object
133
- { width: 25.5 }, // F — Pre-condition
134
- { width: 24.88 }, // G — Test Data
135
- { width: 24.88 }, // H — Steps
136
- { width: 41 }, // I — Expected results
137
- { width: 12 }, // J — Priority
138
- { width: 13.5 }, // K — Testcase type
139
- { width: 15.88 }, // L — Test Result
140
- { width: 13.5 }, // M — Executed Date
141
- { width: 16.5 }, // N — Test Executor
142
- { width: 23 }, // O — Test Environment
143
- { width: 29 }, // P — Note
144
- ];
145
-
146
- // -- Top metadata band (rows 1-4) — 1:1 with template_report.xlsx --
147
- const TIMES = 'Times New Roman';
148
- const ARIAL_FONT = 'Arial';
149
- const BLACK = { argb: 'FF000000' };
150
- const WHITE = { argb: 'FFFFFFFF' };
151
- const LAVENDER = { argb: 'FFD9D2E9' };
152
- const thinBlack = { style: 'thin' as const, color: BLACK };
153
- const allBordersBlack = { top: thinBlack, left: thinBlack, bottom: thinBlack, right: thinBlack };
154
-
155
- // Ensure rows 1..8 exist before merging.
156
- for (let r = 1; r <= 8; r++) ws.getRow(r);
133
+ // Ensure rows 1..4 exist before merging.
134
+ for (let r = 1; r <= 4; r++) ws.getRow(r);
157
135
 
158
136
  ws.mergeCells('A1:C3');
159
137
  ws.mergeCells('D1:F3');
@@ -174,10 +152,6 @@ function addTestcaseSheet(
174
152
  extension: 'png',
175
153
  });
176
154
  // Centre a fixed-size 90×51 logo inside A1:C3 using absolute EMU offsets.
177
- // Col widths (A=20, B=12.5, C=15.38) → pixels via Excel's `w*7+5` rule:
178
- // A=145, B=92.5, C=112.66 ⇒ A1:C3 ≈ 350 px wide.
179
- // Left padding for a 90-px image = (350−90)/2 ≈ 130 px = 1,237,500 EMU.
180
- // 3 default rows × 20 px = 60 px; top padding ≈ 4.5 px ≈ 42,862 EMU.
181
155
  ws.addImage(imageId, {
182
156
  tl: { nativeCol: 0, nativeColOff: 1237500, nativeRow: 0, nativeRowOff: 42862 } as unknown as ExcelJS.Anchor,
183
157
  ext: { width: 90, height: 51 },
@@ -186,7 +160,6 @@ function addTestcaseSheet(
186
160
  } catch { /* logo is decorative — never block export */ }
187
161
 
188
162
  // D1 (TESTCASE title)
189
- const titleLabel = `${summary.screen.toUpperCase()} TESTCASE`;
190
163
  const d1 = ws.getCell('D1');
191
164
  d1.value = titleLabel;
192
165
  d1.font = { bold: true, size: 18, name: TIMES };
@@ -227,6 +200,134 @@ function addTestcaseSheet(
227
200
  g4.font = { bold: true, size: 10, name: TIMES };
228
201
  g4.alignment = { horizontal: 'right', vertical: 'middle', wrapText: true };
229
202
  g4.border = allBordersBlack;
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Overview sheet — the first tab in every multi-sheet delivery workbook.
207
+ // Rolls up each testcase sheet's Passed/Failed/Pending/N/A/Remain/Total into
208
+ // one summary table (+ a grand-total row) under the shared masthead. Reads
209
+ // only each sheet's summary — never touches the detail sheets themselves.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /** Column headers for the Overview roll-up table (cols A..H). */
213
+ const OVERVIEW_HEADERS = ['#', 'Sheet name', 'Passed', 'Failed', 'Pending', 'N/A', 'Remain', 'Total'];
214
+
215
+ /**
216
+ * Prepend an "Overview" worksheet summarizing every sheet in `sheets`.
217
+ *
218
+ * `Remain = Total − Passed − N/A` (i.e. Failed + Pending) so the number
219
+ * reconciles with the "Remaining" cell in each detail sheet's summary band.
220
+ */
221
+ function addOverviewSheet(wb: ExcelJS.Workbook, sheets: LocaleSheet[]): void {
222
+ const ws = wb.addWorksheet('Overview');
223
+
224
+ // Table starts at column B row 7 (A is a thin left margin). Widths keep the
225
+ // A..H masthead readable AND the count columns tidy.
226
+ ws.columns = [
227
+ { width: 4 }, // A — left margin
228
+ { width: 6 }, // B — #
229
+ { width: 26 }, // C — Sheet name
230
+ { width: 13 }, // D — Passed
231
+ { width: 13 }, // E — Failed
232
+ { width: 13 }, // F — Pending
233
+ { width: 13 }, // G — N/A
234
+ { width: 13 }, // H — Remain
235
+ { width: 13 }, // I — Total
236
+ ];
237
+
238
+ const titleScreen = sheets[0]?.summary.screen ?? '';
239
+ renderReportHeaderBand(wb, ws, `${titleScreen.toUpperCase()} TESTCASE`);
240
+
241
+ // Roll-up table: header on row 7, first column B (index 2).
242
+ const HEADER_ROW = 7;
243
+ const FIRST_COL = 2; // column B
244
+ const headerRow = ws.getRow(HEADER_ROW);
245
+ headerRow.height = 20;
246
+ OVERVIEW_HEADERS.forEach((label, i) => {
247
+ const c = headerRow.getCell(FIRST_COL + i);
248
+ c.value = label;
249
+ c.font = { bold: true, size: 10, color: BLACK, name: ARIAL_FONT };
250
+ c.fill = { type: 'pattern', pattern: 'solid', fgColor: LAVENDER };
251
+ c.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
252
+ c.border = allBordersBlack;
253
+ });
254
+
255
+ const totals = { passed: 0, failed: 0, pending: 0, na: 0, remain: 0, total: 0 };
256
+
257
+ const writeDataRow = (rowIdx: number, values: ExcelJS.CellValue[], isTotal: boolean): void => {
258
+ const row = ws.getRow(rowIdx);
259
+ values.forEach((v, i) => {
260
+ const c = row.getCell(FIRST_COL + i);
261
+ c.value = v;
262
+ c.font = { bold: isTotal, size: 10, color: BLACK, name: ARIAL_FONT };
263
+ // The '#' + 'Sheet name' columns read left; the counts centre like the band.
264
+ c.alignment = { horizontal: i <= 1 ? 'left' : 'center', vertical: 'middle' };
265
+ c.border = allBordersBlack;
266
+ if (isTotal) c.fill = { type: 'pattern', pattern: 'solid', fgColor: LAVENDER };
267
+ });
268
+ };
269
+
270
+ let rowIdx = HEADER_ROW + 1;
271
+ sheets.forEach((s, i) => {
272
+ const su = s.summary;
273
+ const remain = Math.max(0, su.total - su.passed - su.na);
274
+ writeDataRow(rowIdx, [i + 1, s.sheetName, su.passed, su.failed, su.pending, su.na, remain, su.total], false);
275
+ totals.passed += su.passed;
276
+ totals.failed += su.failed;
277
+ totals.pending += su.pending;
278
+ totals.na += su.na;
279
+ totals.remain += remain;
280
+ totals.total += su.total;
281
+ rowIdx += 1;
282
+ });
283
+
284
+ writeDataRow(
285
+ rowIdx,
286
+ ['', 'Total', totals.passed, totals.failed, totals.pending, totals.na, totals.remain, totals.total],
287
+ true,
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Append one BM-2-901-13 testcase sheet to the given workbook. Sheet content
293
+ * (layout, summary band, data rows, auto-filter, protection) is identical to
294
+ * the single-sheet `renderXlsx` output — only the sheet name differs.
295
+ */
296
+ function addTestcaseSheet(
297
+ wb: ExcelJS.Workbook,
298
+ sheetName: string,
299
+ summary: ScreenSummary,
300
+ rows: TestCaseRow[],
301
+ specLink: string,
302
+ ): void {
303
+ const ws = wb.addWorksheet(sheetName, {
304
+ views: [{ state: 'frozen', ySplit: 0, xSplit: 0 }],
305
+ });
306
+
307
+ // -- Column widths matching template_report.xlsx (Sample sheet) --
308
+ ws.columns = [
309
+ { width: 20 }, // A — TC ID (wider to fit long flow IDs like FLOW-KUDO-…)
310
+ { width: 12.5 }, // B — Screen/Function
311
+ { width: 15.38 }, // C — Big item
312
+ { width: 16.25 }, // D — Medium item
313
+ { width: 17 }, // E — Test Object
314
+ { width: 25.5 }, // F — Pre-condition
315
+ { width: 24.88 }, // G — Test Data
316
+ { width: 24.88 }, // H — Steps
317
+ { width: 41 }, // I — Expected results
318
+ { width: 12 }, // J — Priority
319
+ { width: 13.5 }, // K — Testcase type
320
+ { width: 15.88 }, // L — Test Result
321
+ { width: 13.5 }, // M — Executed Date
322
+ { width: 16.5 }, // N — Test Executor
323
+ { width: 23 }, // O — Test Environment
324
+ { width: 29 }, // P — Note
325
+ ];
326
+
327
+ // -- Top metadata band (rows 1-4) — shared BM-2-901-13 masthead --
328
+ renderReportHeaderBand(wb, ws, `${summary.screen.toUpperCase()} TESTCASE`);
329
+ // Materialize rows 5..8 so the summary band + row-9 spacer land at the right offsets.
330
+ for (let r = 5; r <= 8; r++) ws.getRow(r);
230
331
 
231
332
  // -- Row 5: spacer (height auto-fit). --
232
333
 
@@ -348,7 +449,12 @@ function addTestcaseSheet(
348
449
  const emittedGroups = new Set<string>();
349
450
 
350
451
  function emitGroup(groupName: string, groupRows: TestCaseRow[]): void {
351
- const divider = ws.addRow(['', groupName]);
452
+ // Column A (TC ID) must stay TRULY blank on a divider never write '' here. COUNTA counts
453
+ // empty-string cells, so an '' in A made the generated total (=COUNTA(A15:A…)) over-count by
454
+ // one per group divider when recalculated (e.g. in Google Sheets). Set only the group name in B;
455
+ // the lavender band still spans every column via the fill loop below (style only, no value).
456
+ const divider = ws.addRow([]);
457
+ divider.getCell(2).value = groupName;
352
458
  divider.getCell(2).font = { bold: true };
353
459
  divider.eachCell({ includeEmpty: false }, (cell) => {
354
460
  cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: CATEGORY_FILL } };
@@ -37,6 +37,13 @@ export interface ParsedScenario {
37
37
  stepsName?: string; // set when scenario has @steps:<name> — marks it as a reusable block
38
38
  extendsName?: string; // set when scenario has @extend:<name> — merges base steps inline
39
39
  hookType?: 'beforeAll' | 'afterEach' | 'afterAll'; // set when scenario has @beforeAll/@afterEach/@afterAll
40
+ /**
41
+ * Comment lines inside the scenario body (between its `Scenario:` line and the next
42
+ * scenario), leading `# ` stripped. `@cucumber/gherkin` keeps comments in a separate
43
+ * top-level array; we range-attach them here so the exporter can surface a @manual
44
+ * scenario's `# Tester verifies:` procedure (its only "steps") in the delivery report.
45
+ */
46
+ comments?: string[];
40
47
  }
41
48
 
42
49
  export interface ParsedFeature {
@@ -82,17 +89,53 @@ export class GherkinParser {
82
89
  const pathMatch = description.match(/^\s*Path:\s*(.+)$/m);
83
90
  const path = pathMatch ? pathMatch[1].trim() : undefined;
84
91
 
92
+ const scenarios = this.parseScenarios(feature);
93
+ this.attachScenarioComments(gherkinDocument, feature, scenarios);
94
+
85
95
  return {
86
96
  name: feature.name,
87
97
  description,
88
98
  path,
89
99
  tags,
90
100
  background: this.parseBackground(feature),
91
- scenarios: this.parseScenarios(feature),
101
+ scenarios,
92
102
  sourceFile: filePath,
93
103
  };
94
104
  }
95
105
 
106
+ /**
107
+ * Range-attach top-level Gherkin comments to the scenario whose body contains them.
108
+ * A comment belongs to scenario i when its line is after scenario i's `Scenario:` line
109
+ * and before scenario i+1's header (its first tag line, or its `Scenario:` line). The
110
+ * leading `# ` is stripped. Used to surface a @manual scenario's tester-procedure comments
111
+ * (its only "steps") in the delivery report.
112
+ */
113
+ private attachScenarioComments(
114
+ doc: Messages.GherkinDocument,
115
+ feature: Messages.Feature,
116
+ scenarios: ParsedScenario[]
117
+ ): void {
118
+ const comments = doc.comments || [];
119
+ if (!comments.length) return;
120
+ const scenChildren = feature.children
121
+ .filter((c) => c.scenario)
122
+ .map((c) => c.scenario!);
123
+ // Same filter/order as parseScenarios → indices line up with `scenarios`.
124
+ for (let i = 0; i < scenChildren.length && i < scenarios.length; i++) {
125
+ const startLine = scenChildren[i].location.line;
126
+ const next = scenChildren[i + 1];
127
+ let endLine = Number.POSITIVE_INFINITY;
128
+ if (next) {
129
+ const tagLines = (next.tags || []).map((t) => t.location.line);
130
+ endLine = Math.min(next.location.line, ...(tagLines.length ? tagLines : [next.location.line]));
131
+ }
132
+ const body = comments
133
+ .filter((cm) => cm.location.line > startLine && cm.location.line < endLine)
134
+ .map((cm) => cm.text.replace(/^\s*#\s?/, '').replace(/\s+$/, ''));
135
+ if (body.length) scenarios[i].comments = body;
136
+ }
137
+ }
138
+
96
139
  /**
97
140
  * Parse background steps
98
141
  */