@sun-asterisk/sungen 3.2.3 → 3.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/dist/cli/commands/dashboard.js +2 -2
  2. package/dist/cli/commands/dashboard.js.map +1 -1
  3. package/dist/cli/commands/delivery.d.ts.map +1 -1
  4. package/dist/cli/commands/delivery.js +22 -5
  5. package/dist/cli/commands/delivery.js.map +1 -1
  6. package/dist/cli/commands/feedback.d.ts +11 -0
  7. package/dist/cli/commands/feedback.d.ts.map +1 -1
  8. package/dist/cli/commands/feedback.js +154 -57
  9. package/dist/cli/commands/feedback.js.map +1 -1
  10. package/dist/cli/commands/login.d.ts +19 -0
  11. package/dist/cli/commands/login.d.ts.map +1 -0
  12. package/dist/cli/commands/login.js +76 -0
  13. package/dist/cli/commands/login.js.map +1 -0
  14. package/dist/cli/commands/qa-feedback.d.ts +14 -0
  15. package/dist/cli/commands/qa-feedback.d.ts.map +1 -0
  16. package/dist/cli/commands/qa-feedback.js +83 -0
  17. package/dist/cli/commands/qa-feedback.js.map +1 -0
  18. package/dist/cli/index.js +4 -0
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/dashboard/snapshot-builder.d.ts +3 -0
  21. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  22. package/dist/dashboard/snapshot-builder.js +155 -52
  23. package/dist/dashboard/snapshot-builder.js.map +1 -1
  24. package/dist/dashboard/templates/index.html +23 -23
  25. package/dist/dashboard/types.d.ts +8 -0
  26. package/dist/dashboard/types.d.ts.map +1 -1
  27. package/dist/exporters/api-testcase-formatter.d.ts +55 -0
  28. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -0
  29. package/dist/exporters/api-testcase-formatter.js +304 -0
  30. package/dist/exporters/api-testcase-formatter.js.map +1 -0
  31. package/dist/exporters/csv-exporter.d.ts +13 -1
  32. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  33. package/dist/exporters/csv-exporter.js +35 -6
  34. package/dist/exporters/csv-exporter.js.map +1 -1
  35. package/dist/exporters/feature-parser.d.ts +4 -0
  36. package/dist/exporters/feature-parser.d.ts.map +1 -1
  37. package/dist/exporters/feature-parser.js +7 -2
  38. package/dist/exporters/feature-parser.js.map +1 -1
  39. package/dist/exporters/json-exporter.d.ts.map +1 -1
  40. package/dist/exporters/json-exporter.js +11 -3
  41. package/dist/exporters/json-exporter.js.map +1 -1
  42. package/dist/exporters/playwright-report-parser.d.ts +21 -2
  43. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  44. package/dist/exporters/playwright-report-parser.js +52 -12
  45. package/dist/exporters/playwright-report-parser.js.map +1 -1
  46. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  47. package/dist/exporters/scenario-merger.js +50 -2
  48. package/dist/exporters/scenario-merger.js.map +1 -1
  49. package/dist/exporters/spec-parser.js +1 -1
  50. package/dist/exporters/spec-parser.js.map +1 -1
  51. package/dist/exporters/test-data-resolver.d.ts +7 -0
  52. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  53. package/dist/exporters/test-data-resolver.js +13 -0
  54. package/dist/exporters/test-data-resolver.js.map +1 -1
  55. package/dist/exporters/types.d.ts +13 -0
  56. package/dist/exporters/types.d.ts.map +1 -1
  57. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  58. package/dist/exporters/xlsx-exporter.js +131 -42
  59. package/dist/exporters/xlsx-exporter.js.map +1 -1
  60. package/dist/generators/gherkin-parser/index.d.ts +15 -0
  61. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  62. package/dist/generators/gherkin-parser/index.js +33 -1
  63. package/dist/generators/gherkin-parser/index.js.map +1 -1
  64. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
  65. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
  66. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
  67. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
  68. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
  69. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
  70. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
  71. package/dist/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
  72. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
  73. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
  74. package/dist/generators/test-generator/patterns/index.d.ts +12 -0
  75. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  76. package/dist/generators/test-generator/patterns/index.js +21 -1
  77. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  78. package/dist/harness/script-check.d.ts.map +1 -1
  79. package/dist/harness/script-check.js +10 -5
  80. package/dist/harness/script-check.js.map +1 -1
  81. package/dist/orchestrator/ai-skills/writer.d.ts.map +1 -1
  82. package/dist/orchestrator/ai-skills/writer.js +46 -4
  83. package/dist/orchestrator/ai-skills/writer.js.map +1 -1
  84. package/dist/orchestrator/project-initializer.d.ts +14 -0
  85. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  86. package/dist/orchestrator/project-initializer.js +58 -0
  87. package/dist/orchestrator/project-initializer.js.map +1 -1
  88. package/dist/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
  89. package/dist/orchestrator/templates/ai-src/commands/create-test.md +7 -0
  90. package/dist/orchestrator/templates/ai-src/commands/feedback.md +6 -6
  91. package/dist/orchestrator/templates/ai-src/commands/locale.md +26 -4
  92. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
  93. package/dist/orchestrator/templates/wdio.conf.ts +39 -3
  94. package/dist/tools/sungen-api/edit-in-editor.d.ts +15 -0
  95. package/dist/tools/sungen-api/edit-in-editor.d.ts.map +1 -0
  96. package/dist/tools/sungen-api/edit-in-editor.js +85 -0
  97. package/dist/tools/sungen-api/edit-in-editor.js.map +1 -0
  98. package/dist/tools/sungen-api/feedback-client.d.ts +24 -0
  99. package/dist/tools/sungen-api/feedback-client.d.ts.map +1 -0
  100. package/dist/tools/sungen-api/feedback-client.js +53 -0
  101. package/dist/tools/sungen-api/feedback-client.js.map +1 -0
  102. package/dist/tools/sungen-api/read-stdin.d.ts +6 -0
  103. package/dist/tools/sungen-api/read-stdin.d.ts.map +1 -0
  104. package/dist/tools/sungen-api/read-stdin.js +19 -0
  105. package/dist/tools/sungen-api/read-stdin.js.map +1 -0
  106. package/dist/tools/sungen-auth/api-base-url.d.ts +14 -0
  107. package/dist/tools/sungen-auth/api-base-url.d.ts.map +1 -0
  108. package/dist/tools/sungen-auth/api-base-url.js +28 -0
  109. package/dist/tools/sungen-auth/api-base-url.js.map +1 -0
  110. package/dist/tools/sungen-auth/credentials-store.d.ts +19 -0
  111. package/dist/tools/sungen-auth/credentials-store.d.ts.map +1 -0
  112. package/dist/tools/sungen-auth/credentials-store.js +94 -0
  113. package/dist/tools/sungen-auth/credentials-store.js.map +1 -0
  114. package/dist/tools/sungen-auth/login-flow.d.ts +33 -0
  115. package/dist/tools/sungen-auth/login-flow.d.ts.map +1 -0
  116. package/dist/tools/sungen-auth/login-flow.js +107 -0
  117. package/dist/tools/sungen-auth/login-flow.js.map +1 -0
  118. package/dist/tools/sungen-auth/loopback-server.d.ts +20 -0
  119. package/dist/tools/sungen-auth/loopback-server.d.ts.map +1 -0
  120. package/dist/tools/sungen-auth/loopback-server.js +132 -0
  121. package/dist/tools/sungen-auth/loopback-server.js.map +1 -0
  122. package/dist/tools/sungen-auth/open-browser.d.ts +10 -0
  123. package/dist/tools/sungen-auth/open-browser.d.ts.map +1 -0
  124. package/dist/tools/sungen-auth/open-browser.js +43 -0
  125. package/dist/tools/sungen-auth/open-browser.js.map +1 -0
  126. package/dist/tools/sungen-auth/pkce.d.ts +13 -0
  127. package/dist/tools/sungen-auth/pkce.d.ts.map +1 -0
  128. package/dist/tools/sungen-auth/pkce.js +27 -0
  129. package/dist/tools/sungen-auth/pkce.js.map +1 -0
  130. package/dist/tools/sungen-auth/sungen-auth-types.d.ts +41 -0
  131. package/dist/tools/sungen-auth/sungen-auth-types.d.ts.map +1 -0
  132. package/dist/tools/sungen-auth/sungen-auth-types.js +7 -0
  133. package/dist/tools/sungen-auth/sungen-auth-types.js.map +1 -0
  134. package/package.json +4 -3
  135. package/src/cli/commands/dashboard.ts +2 -2
  136. package/src/cli/commands/delivery.ts +24 -6
  137. package/src/cli/commands/feedback.ts +123 -51
  138. package/src/cli/commands/login.ts +75 -0
  139. package/src/cli/commands/qa-feedback.ts +77 -0
  140. package/src/cli/index.ts +4 -0
  141. package/src/dashboard/snapshot-builder.ts +146 -46
  142. package/src/dashboard/templates/index.html +23 -23
  143. package/src/dashboard/types.ts +10 -2
  144. package/src/exporters/api-testcase-formatter.ts +325 -0
  145. package/src/exporters/csv-exporter.ts +46 -7
  146. package/src/exporters/feature-parser.ts +7 -2
  147. package/src/exporters/json-exporter.ts +11 -3
  148. package/src/exporters/playwright-report-parser.ts +50 -11
  149. package/src/exporters/scenario-merger.ts +49 -3
  150. package/src/exporters/spec-parser.ts +1 -1
  151. package/src/exporters/test-data-resolver.ts +13 -0
  152. package/src/exporters/types.ts +13 -0
  153. package/src/exporters/xlsx-exporter.ts +155 -49
  154. package/src/generators/gherkin-parser/index.ts +44 -1
  155. package/src/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
  156. package/src/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
  157. package/src/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
  158. package/src/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
  159. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
  160. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
  161. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
  162. package/src/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
  163. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
  164. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
  165. package/src/generators/test-generator/patterns/index.ts +21 -1
  166. package/src/harness/script-check.ts +10 -5
  167. package/src/orchestrator/ai-skills/writer.ts +39 -4
  168. package/src/orchestrator/project-initializer.ts +59 -0
  169. package/src/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
  170. package/src/orchestrator/templates/ai-src/commands/create-test.md +7 -0
  171. package/src/orchestrator/templates/ai-src/commands/feedback.md +6 -6
  172. package/src/orchestrator/templates/ai-src/commands/locale.md +26 -4
  173. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
  174. package/src/orchestrator/templates/wdio.conf.ts +39 -3
  175. package/src/tools/sungen-api/edit-in-editor.ts +48 -0
  176. package/src/tools/sungen-api/feedback-client.ts +62 -0
  177. package/src/tools/sungen-api/read-stdin.ts +15 -0
  178. package/src/tools/sungen-auth/api-base-url.ts +23 -0
  179. package/src/tools/sungen-auth/credentials-store.ts +57 -0
  180. package/src/tools/sungen-auth/login-flow.ts +135 -0
  181. package/src/tools/sungen-auth/loopback-server.ts +113 -0
  182. package/src/tools/sungen-auth/open-browser.ts +42 -0
  183. package/src/tools/sungen-auth/pkce.ts +27 -0
  184. package/src/tools/sungen-auth/sungen-auth-types.ts +45 -0
@@ -39,14 +39,20 @@ export interface AggregateSummary {
39
39
  }
40
40
 
41
41
  export interface ScreenSnapshot {
42
- name: string; // "kudos" or "flow/checkout" — the screen DIRECTORY name
42
+ name: string; // "kudos", "flow/checkout", or "api/login" — the unit label
43
43
  isFlow: boolean;
44
+ /** Unit kind — screen | flow | api. Drives the dashboard's suite badge so API
45
+ * suites read as API rather than being mislabelled "web". Optional so history
46
+ * snapshots written before this field still parse (they render as screen). */
47
+ kind?: 'screen' | 'flow' | 'api';
48
+ platform?: 'web' | 'android' | 'ios' | 'mobile'; // 'mobile' = @platform:mobile screen (per-OS features below)
44
49
  featureName: string; // name of the primary (first) feature; kept for backwards compat
45
50
  featurePath?: string; // entry-point path of the primary feature
46
51
  specLink?: string; // relative path to requirements/spec.md
47
52
  summary: ScreenSummaryStats; // rollup across all features
48
53
  scenarios: ScenarioSnapshot[]; // flat list across all features (each carries its `featureBaseName`)
49
54
  features?: FeatureSnapshot[]; // present when a screen has 1+ .feature files; UI groups by this
55
+ executedPlatforms?: ('android' | 'ios')[]; // OSes that produced a result file this run (mobile only)
50
56
  }
51
57
 
52
58
  /**
@@ -67,7 +73,9 @@ export interface FeatureSnapshot {
67
73
  specLink?: string; // relative path to requirements/spec.md, when present
68
74
  summary: ScreenSummaryStats; // BASE locale rollup — back-compat for non-locale consumers
69
75
  scenarios: ScenarioSnapshot[]; // BASE locale scenarios
70
- locales?: LocaleSnapshot[]; // present when 2+ locales detected (base + at least one overlay)
76
+ locales?: LocaleSnapshot[]; // locale overlays WITHIN this entry (base + at least one overlay)
77
+ platform?: 'android' | 'ios'; // one OS run of a @platform:mobile feature (separate entry per OS)
78
+ executedPlatforms?: ('android' | 'ios')[]; // OSes that produced a result file this run (mobile only)
71
79
  }
72
80
 
73
81
  /**
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Render an API test case's Steps / Expected-results cells for the delivery report.
3
+ *
4
+ * API scenarios carry no Given/When steps and assert against runtime response
5
+ * refs (`expect {{endpoint.status}} is 201`), so the generic row builder leaves
6
+ * the Steps cell empty and the Expected cell showing raw `{{…}}`. This module
7
+ * turns the scenario's `@api:<name>(args)` tag + the apis.yaml catalog into a
8
+ * human-readable request block (Method / API / Header / Body) and rewrites the
9
+ * assertions into "Status / Response" language.
10
+ *
11
+ * Deterministic — no AI, no network. Everything is sourced from the catalog +
12
+ * scenario tags + test-data, mirroring exactly what the generated request sends
13
+ * (see orchestrator/templates/specs-api.ts: catalog headers + a Content-Type
14
+ * implied by the body wire format; `:param` falls back to the same-named
15
+ * test-data key when the tag omits it).
16
+ */
17
+
18
+ import { ApiCatalogEntry } from './types';
19
+
20
+ /** A single `@api:<name>(k=v,…)` invocation parsed from a scenario's tags. */
21
+ export interface ApiCall {
22
+ name: string;
23
+ /** param → value expression, kept raw: `{{new_email}}`, `"literal"`, `3`. */
24
+ args: Record<string, string>;
25
+ }
26
+
27
+ /** True when the scenario invokes at least one `@api:` endpoint. */
28
+ export function hasApiCalls(tags: string[]): boolean {
29
+ return tags.some((t) => t.startsWith('@api:'));
30
+ }
31
+
32
+ /**
33
+ * Parse the ordered `@api:<name>(arg=expr,…)` invocations from a scenario's tags.
34
+ * Mirrors the driver's override grammar: split the top-level comma list, then the
35
+ * first `=` per pair. Values stay as raw expressions so the caller resolves them
36
+ * the same way the runtime does.
37
+ */
38
+ /**
39
+ * Same shape the runtime accepts (`driver-api/src/index.ts`): `@api:<name>` with an
40
+ * optional balanced `(...)` arg list. A malformed tag (e.g. unclosed paren) is rejected
41
+ * here too, so the report never documents a call the runtime silently dropped.
42
+ */
43
+ const API_TAG = /^@api:([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?$/;
44
+
45
+ export function parseApiCalls(tags: string[]): ApiCall[] {
46
+ const calls: ApiCall[] = [];
47
+ for (const t of tags) {
48
+ const m = API_TAG.exec(t);
49
+ if (!m) continue;
50
+ const args: Record<string, string> = {};
51
+ for (const pair of splitTopLevel(m[2] ?? '')) {
52
+ const eq = pair.indexOf('=');
53
+ if (eq < 0) continue;
54
+ args[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
55
+ }
56
+ calls.push({ name: m[1], args });
57
+ }
58
+ return calls;
59
+ }
60
+
61
+ /**
62
+ * Build the "Steps" cell for an API test case: one block per invoked endpoint,
63
+ * showing Method / API (resolved path) / Header / Body. Endpoints absent from the
64
+ * catalog are skipped. Returns '' when nothing resolves (caller keeps generic Steps).
65
+ *
66
+ * `localize` resolves `{{var}}` test-data placeholders (and selector keys) to
67
+ * concrete values — the same resolver the row builder uses for UI steps.
68
+ */
69
+ export function formatApiRequest(
70
+ tags: string[],
71
+ catalog: Record<string, ApiCatalogEntry>,
72
+ localize: (text: string) => string,
73
+ ): string {
74
+ const calls = parseApiCalls(tags).filter((c) => catalog[c.name]);
75
+ if (calls.length === 0) return '';
76
+
77
+ const blocks = calls.map((call) => {
78
+ const entry = catalog[call.name];
79
+ const resolve = (raw: string): string => localize(bindParams(raw, call.args));
80
+ // Coerce method/path — loadApiCatalog is a raw passthrough, so a malformed entry
81
+ // (numeric method/path) must degrade gracefully, not crash the unit's export.
82
+ const method = String(entry.method ?? '').toUpperCase() || '—';
83
+ const api = entry.path ? resolve(String(entry.path)) : '—';
84
+ const header = formatHeaders(entry, call.args, localize);
85
+ const bodyStr = formatBody(entry.body, call.args, localize);
86
+ return [`Method: ${method}`, `API: ${api}`, `Header: ${header}`, `Body: ${bodyStr}`].join('\n');
87
+ });
88
+
89
+ if (blocks.length === 1) return blocks[0];
90
+ // Multiple endpoints (flow) — number them so the call order is explicit.
91
+ return blocks
92
+ .map((b, i) => b.split('\n').map((l, j) => (j === 0 ? `${i + 1}. ${l}` : ` ${l}`)).join('\n'))
93
+ .join('\n');
94
+ }
95
+
96
+ /**
97
+ * Rewrite an API scenario's `Then` assertions into readable Status / Response lines.
98
+ * Raw assertions reference the runtime response object
99
+ * (`expect {{endpoint.status}} is 201`, `expect {{endpoint.body.email}} is {{x}}`);
100
+ * we strip the endpoint prefix (a runtime var, meaningless to a reader), keep the
101
+ * field path, label status vs body, and resolve `{{var}}` on the right-hand side.
102
+ */
103
+ export function formatApiExpected(
104
+ expectedSteps: Array<{ text: string }>,
105
+ tags: string[],
106
+ localize: (text: string) => string,
107
+ ): string {
108
+ const endpointNames = parseApiCalls(tags).map((c) => c.name);
109
+ const oracleNames = parseQueryOracles(tags);
110
+ const lines = expectedSteps
111
+ .map((s) => humanizeAssertion(s.text, endpointNames, oracleNames, localize))
112
+ .filter((l) => l.length > 0);
113
+ if (lines.length === 0) return '';
114
+ if (lines.length === 1) return lines[0];
115
+ return lines.map((l, i) => `${i + 1}. ${l}`).join('\n');
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Internals
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /** Split a comma list, ignoring commas nested inside {}/[]/() so `{a,b}` stays whole. */
123
+ function splitTopLevel(s: string): string[] {
124
+ const out: string[] = [];
125
+ let depth = 0;
126
+ let cur = '';
127
+ for (const ch of s) {
128
+ if (ch === '{' || ch === '[' || ch === '(') depth++;
129
+ else if (ch === '}' || ch === ']' || ch === ')') depth = Math.max(0, depth - 1);
130
+ if (ch === ',' && depth === 0) {
131
+ out.push(cur);
132
+ cur = '';
133
+ } else {
134
+ cur += ch;
135
+ }
136
+ }
137
+ if (cur.trim().length > 0) out.push(cur);
138
+ return out;
139
+ }
140
+
141
+ /**
142
+ * Substitute `:param` tokens (path / body / header templates) with the invocation's
143
+ * arg expression, falling back to `{{param}}` when the tag omits it — matching the
144
+ * runtime, where an unbound param resolves from the same-named test-data key.
145
+ */
146
+ function bindParams(raw: string, args: Record<string, string>): string {
147
+ return raw.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, p: string) => {
148
+ const expr = p in args ? args[p] : `{{${p}}}`;
149
+ return unquoteExpr(expr);
150
+ });
151
+ }
152
+
153
+ /** Strip a matching pair of surrounding quotes from a tag-arg literal (`"x"` → `x`). */
154
+ function unquoteExpr(e: string): string {
155
+ const m = e.match(/^"(.*)"$/) || e.match(/^'(.*)'$/);
156
+ return m ? m[1] : e;
157
+ }
158
+
159
+ /**
160
+ * Compose the Header cell from catalog `headers:` (with `:param` bound + `{{var}}`
161
+ * resolved) plus the Content-Type implied by the body wire format — Playwright sets
162
+ * that Content-Type from the encoding option at runtime (specs-api.ts), shown only
163
+ * when a body is sent and the catalog didn't already declare one. Returns '—' when
164
+ * the request has none.
165
+ *
166
+ * Scope: this reflects the per-endpoint catalog headers only. Datasource-level headers
167
+ * (`conf.headers` from the datasources config, merged at runtime) are NOT read here —
168
+ * the exporter doesn't load `.env.qa`/datasources — so the cell is a catalog view, not
169
+ * a byte-for-byte copy of the wire request.
170
+ */
171
+ function formatHeaders(
172
+ entry: ApiCatalogEntry,
173
+ args: Record<string, string>,
174
+ localize: (text: string) => string,
175
+ ): string {
176
+ const explicit = entry.headers && typeof entry.headers === 'object' ? entry.headers : {};
177
+ const hasContentType = Object.keys(explicit).some((k) => k.toLowerCase() === 'content-type');
178
+ // Auth headers (authorization/cookie/api-key…) carry credentials — show the scheme
179
+ // but mask the value. A value-based redaction pass (redactSecrets) backstops this.
180
+ const parts = Object.entries(explicit).map(([k, v]) =>
181
+ isSensitiveHeader(k) ? `${k}: ${maskHeaderValue(String(v))}` : `${k}: ${localize(bindParams(String(v), args))}`,
182
+ );
183
+ if (!hasContentType && entry.body !== undefined && entry.body !== null) {
184
+ parts.push(`Content-Type: ${contentTypeFor(entry.encoding)}`);
185
+ }
186
+ return parts.length > 0 ? parts.join('\n') : '—';
187
+ }
188
+
189
+ function contentTypeFor(encoding?: string): string {
190
+ if (encoding === 'form') return 'application/x-www-form-urlencoded';
191
+ if (encoding === 'multipart') return 'multipart/form-data';
192
+ return 'application/json';
193
+ }
194
+
195
+ /**
196
+ * Compose the Body cell, masking credential fields. When the body is a flat object
197
+ * with a sensitive key (password/token/secret/…), each value is rendered per-key so
198
+ * that field can be replaced with `***`; otherwise the body resolves wholesale
199
+ * (preserving numbers/nesting exactly as before).
200
+ */
201
+ function formatBody(body: unknown, args: Record<string, string>, localize: (text: string) => string): string {
202
+ if (body === undefined || body === null) return '—';
203
+ if (isPlainObject(body)) {
204
+ const obj = body as Record<string, unknown>;
205
+ if (Object.keys(obj).some(isSensitiveField)) {
206
+ const out: Record<string, string> = {};
207
+ for (const [k, v] of Object.entries(obj)) {
208
+ out[k] = isSensitiveField(k) ? '***' : localize(bindParams(typeof v === 'string' ? v : JSON.stringify(v), args));
209
+ }
210
+ return JSON.stringify(out);
211
+ }
212
+ }
213
+ return localize(bindParams(typeof body === 'string' ? body : JSON.stringify(body), args));
214
+ }
215
+
216
+ function isPlainObject(v: unknown): boolean {
217
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
218
+ }
219
+
220
+ /**
221
+ * Credential name matcher (substring, not anchored) for both body-field keys and
222
+ * test-data keys — catches variants like confirmPassword / new_password /
223
+ * password_confirmation / access_token / client_secret / otp / passcode.
224
+ */
225
+ const SENSITIVE_NAME = /pass(word|code)?|passwd|pwd|secret|token|otp|api[-_]?key|apikey|credential|authorization/i;
226
+ function isSensitiveField(key: string): boolean {
227
+ return SENSITIVE_NAME.test(key);
228
+ }
229
+
230
+ /**
231
+ * Resolved values of sensitive test-data keys — used for a value-based redaction pass
232
+ * over the finished Steps/Expected cells, closing the paths key-based masking misses
233
+ * (nested request bodies, query-string credentials, and the Expected assertion cell).
234
+ * Values shorter than 3 chars are skipped to avoid masking trivial/coincidental text.
235
+ */
236
+ export function collectSecretValues(testData: Record<string, string>): string[] {
237
+ const secrets: string[] = [];
238
+ for (const [k, v] of Object.entries(testData)) {
239
+ if (typeof v === 'string' && v.length >= 3 && SENSITIVE_NAME.test(k)) secrets.push(v);
240
+ }
241
+ // Longest-first so a longer secret is redacted before any shorter substring of it.
242
+ return [...new Set(secrets)].sort((a, b) => b.length - a.length);
243
+ }
244
+
245
+ /** Replace every occurrence of each secret value with `***` (literal, not regex). */
246
+ export function redactSecrets(text: string, secrets: string[]): string {
247
+ if (secrets.length === 0 || !text) return text;
248
+ let out = text;
249
+ for (const s of secrets) out = out.split(s).join('***');
250
+ return out;
251
+ }
252
+
253
+ /** Header names that carry credentials — value masked, scheme kept. */
254
+ const SENSITIVE_HEADER = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|x-auth-token|x-access-token|x-session-token)$/i;
255
+ function isSensitiveHeader(name: string): boolean {
256
+ return SENSITIVE_HEADER.test(name);
257
+ }
258
+
259
+ /** Keep an auth scheme prefix (Bearer/Basic/…) but hide the credential. */
260
+ function maskHeaderValue(value: string): string {
261
+ const m = value.match(/^(Bearer|Basic|Digest|Token)\b/i);
262
+ return m ? `${m[1]} ***` : '***';
263
+ }
264
+
265
+ /** Ordered `@query:<oracle>` DB-oracle names (a @concurrent idempotency cross-check). */
266
+ function parseQueryOracles(tags: string[]): string[] {
267
+ return tags
268
+ .filter((t) => t.startsWith('@query:'))
269
+ .map((t) => {
270
+ const body = t.slice('@query:'.length);
271
+ const parenIdx = body.indexOf('(');
272
+ return parenIdx >= 0 ? body.slice(0, parenIdx) : body;
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Turn one raw assertion line into a readable Status/Response statement.
278
+ * `{{<endpoint>.<field>}}` → a labelled response reference; `{{<oracle>.<field>}}`
279
+ * (a @query DB oracle) → the plain dotted path; remaining `{{var}}` (genuine
280
+ * test-data on the right-hand side) is resolved by `localize`.
281
+ */
282
+ function humanizeAssertion(
283
+ raw: string,
284
+ endpointNames: string[],
285
+ oracleNames: string[],
286
+ localize: (text: string) => string,
287
+ ): string {
288
+ let s = raw.trim().replace(/^expect\s+/i, '');
289
+ // Field path allows array-index segments so @concurrent refs like
290
+ // `{{login.responses[0].body.token}}` are matched, not left as raw `{{…}}`.
291
+ const FIELD = '(?:[\\w.]|\\[\\d+\\])+';
292
+ if (endpointNames.length > 0) {
293
+ const alt = endpointNames.map(escapeRe).join('|');
294
+ s = s.replace(new RegExp(`\\{\\{\\s*(?:${alt})\\.(${FIELD})\\s*\\}\\}`, 'g'), (_m, field: string) =>
295
+ responseFieldLabel(field),
296
+ );
297
+ }
298
+ if (oracleNames.length > 0) {
299
+ // @query oracle refs read as a DB check — strip the `{{ }}` noise, keep `oracle.field`.
300
+ const alt = oracleNames.map(escapeRe).join('|');
301
+ s = s.replace(new RegExp(`\\{\\{\\s*((?:${alt})\\.${FIELD})\\s*\\}\\}`, 'g'), (_m, ref: string) => ref);
302
+ }
303
+ s = localize(s);
304
+ if (s.length === 0) return s;
305
+ // Don't recase a line that leads with a @query oracle identifier (a code name) —
306
+ // `user_count.count is 1` must not become `User_count.count is 1`.
307
+ const leadsWithOracle = oracleNames.some((o) => s === o || s.startsWith(`${o}.`));
308
+ return leadsWithOracle ? s : s.charAt(0).toUpperCase() + s.slice(1);
309
+ }
310
+
311
+ /** Map a response field path (after the `<endpoint>.` prefix) to reader-friendly text. */
312
+ function responseFieldLabel(field: string): string {
313
+ if (field === 'status') return 'Status';
314
+ if (field === 'ok') return 'Response ok';
315
+ if (field === 'ok_count') return 'Success count';
316
+ if (field === 'body') return 'Response body';
317
+ if (field.startsWith('body.')) return `Response body.${field.slice('body.'.length)}`;
318
+ if (field.startsWith('status_counts.')) return `Count of ${field.slice('status_counts.'.length)} responses`;
319
+ if (field.startsWith('headers.')) return `Response header "${field.slice('headers.'.length)}"`;
320
+ return `Response ${field}`;
321
+ }
322
+
323
+ function escapeRe(s: string): string {
324
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
325
+ }
@@ -16,13 +16,14 @@ 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,
23
23
  statusToTestResult,
24
24
  } from './playwright-report-parser';
25
- import { EnvironmentInfo, PlaywrightResult, ScreenSummary, TestCaseRow } from './types';
25
+ import { ApiCatalogEntry, EnvironmentInfo, PlaywrightResult, ScreenSummary, TestCaseRow } from './types';
26
+ import { collectSecretValues, formatApiExpected, formatApiRequest, hasApiCalls, redactSecrets } from './api-testcase-formatter';
26
27
  import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
27
28
 
28
29
  export interface BuildCsvInput {
@@ -43,6 +44,18 @@ export interface BuildCsvInput {
43
44
  * instead of the VI selector key "Click [VỀ KUDOS] link".
44
45
  */
45
46
  selectorKeyMap?: SelectorKeyMap;
47
+ /**
48
+ * Unit kind. When `'api'`, the row builder renders the request contract
49
+ * (Method/API/Header/Body) into the Steps cell and the response contract
50
+ * (Status + Response) into the Expected cell — API scenarios otherwise leave
51
+ * Steps empty and Expected showing raw `{{endpoint.field}}` refs.
52
+ */
53
+ kind?: 'screen' | 'flow' | 'api';
54
+ /**
55
+ * apis.yaml catalog (keyed by endpoint name) for api-kind units. Supplies the
56
+ * Method/path/headers/body/encoding the readable request block is built from.
57
+ */
58
+ apiCatalog?: Record<string, ApiCatalogEntry>;
46
59
  }
47
60
 
48
61
  /**
@@ -83,23 +96,49 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
83
96
  // Pre-condition is auth-only. Steps section = Background + @extend base
84
97
  // (entire) + own pre-Then. Expected = own's Then-onwards only.
85
98
  const precondition = formatPrecondition(authRole);
86
- const localize = (text: string): string =>
87
- input.selectorKeyMap
99
+ const localize = (text: string): string => {
100
+ // 1. resolve [Reference] selector keys → their label/value; 2. resolve {{var}} test-data
101
+ // placeholders → concrete values, so Steps / Expected results show what QA actually verifies
102
+ // (e.g. `text contains {{app_version}}` → `text contains Version 1.0.0`) instead of the raw
103
+ // template. An unknown {{var}} is left literal.
104
+ const withKeys = input.selectorKeyMap
88
105
  ? substituteSelectorKeysInStep(text, input.selectorKeyMap, input.testData)
89
106
  : text;
107
+ return substituteTestDataVars(withKeys, input.testData);
108
+ };
90
109
  const formatStep = (s: { text: string; bucket: 'given' | 'when' | 'then' }) =>
91
110
  localize(s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text));
92
111
  const stepLines = m.resolvedSteps.map(formatStep).filter(Boolean);
93
112
  const expectedLines = m.resolvedExpected.map(formatStep).filter(Boolean);
94
- const steps =
113
+ let steps =
95
114
  stepLines.length === 0 ? '' :
96
115
  stepLines.length === 1 ? stepLines[0] :
97
116
  stepLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
98
- const expectedResults =
117
+ let expectedResults =
99
118
  expectedLines.length === 0 ? '' :
100
119
  expectedLines.length === 1 ? expectedLines[0] :
101
120
  expectedLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
102
121
 
122
+ // API test cases: the scenario has no Given/When (Steps empty) and asserts
123
+ // against runtime response refs (`{{endpoint.status}}` — left literal since
124
+ // they aren't test-data keys). Render the request contract into Steps and the
125
+ // response contract into Expected from the @api tag + apis.yaml catalog, so
126
+ // QA reads Method/API/Header/Body and Status/Response instead of raw refs.
127
+ if (input.kind === 'api' && input.apiCatalog && hasApiCalls(m.feature.tags)) {
128
+ const apiRequest = formatApiRequest(m.feature.tags, input.apiCatalog, localize);
129
+ if (apiRequest) steps = steps ? `${steps}\n\n${apiRequest}` : apiRequest;
130
+ const apiExpected = formatApiExpected(m.resolvedExpected, m.feature.tags, localize);
131
+ if (apiExpected) expectedResults = apiExpected;
132
+ // Value-based redaction backstop: mask resolved credential values (from
133
+ // sensitively-named test-data keys) anywhere in the API cells — closes the
134
+ // nested-body, query-string and Expected-cell paths key-based masking misses.
135
+ const secrets = collectSecretValues(input.testData);
136
+ if (secrets.length > 0) {
137
+ steps = redactSecrets(steps, secrets);
138
+ expectedResults = redactSecrets(expectedResults, secrets);
139
+ }
140
+ }
141
+
103
142
  // Multi-line bullet format keeps delivery CSV/XLSX in sync with the
104
143
  // dashboard's Test data section. Multi-line CSV cells are valid (quoted
105
144
  // automatically) and render natively as multi-line in XLSX.
@@ -127,7 +166,7 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
127
166
  } else {
128
167
  testResult = statusToTestResult(result.status);
129
168
  executedDate = formatExecutedDate(result.startTime);
130
- note = formatNote(result);
169
+ note = formatNote(result, expectedResults);
131
170
  environment = formatEnv(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
132
171
  executor = input.env.executor;
133
172
  }
@@ -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
  }