@sun-asterisk/sungen 3.2.4 → 3.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/dashboard.js +2 -2
- package/dist/cli/commands/dashboard.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +22 -5
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/dashboard/snapshot-builder.d.ts +3 -0
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
- package/dist/dashboard/snapshot-builder.js +47 -19
- package/dist/dashboard/snapshot-builder.js.map +1 -1
- package/dist/dashboard/templates/index.html +4 -4
- package/dist/dashboard/types.d.ts +4 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/exporters/api-testcase-formatter.d.ts +55 -0
- package/dist/exporters/api-testcase-formatter.d.ts.map +1 -0
- package/dist/exporters/api-testcase-formatter.js +304 -0
- package/dist/exporters/api-testcase-formatter.js.map +1 -0
- package/dist/exporters/csv-exporter.d.ts +13 -1
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +44 -28
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +6 -0
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +9 -3
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/json-exporter.d.ts.map +1 -1
- package/dist/exporters/json-exporter.js +58 -48
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/result-variants.d.ts +29 -0
- package/dist/exporters/result-variants.d.ts.map +1 -0
- package/dist/exporters/result-variants.js +33 -0
- package/dist/exporters/result-variants.js.map +1 -0
- package/dist/exporters/types.d.ts +7 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.js +45 -13
- package/dist/exporters/xlsx-exporter.js.map +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
- package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/expect-patterns.js +23 -0
- package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -1
- package/dist/generators/test-generator/patterns/index.d.ts +12 -0
- package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/index.js +21 -1
- package/dist/generators/test-generator/patterns/index.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts +7 -0
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +13 -2
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/orchestrator/project-initializer.d.ts +23 -0
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +118 -3
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-api.md +76 -0
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/dist/orchestrator/templates/ai-src/config/codex.md +3 -2
- package/dist/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
- package/dist/orchestrator/templates/specs-api.d.ts +26 -0
- package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-api.js +131 -3
- package/dist/orchestrator/templates/specs-api.js.map +1 -1
- package/dist/orchestrator/templates/specs-api.ts +118 -5
- package/dist/orchestrator/templates/specs-test-data.ts +20 -1
- package/package.json +4 -3
- package/src/cli/commands/dashboard.ts +2 -2
- package/src/cli/commands/delivery.ts +24 -6
- package/src/dashboard/snapshot-builder.ts +46 -19
- package/src/dashboard/templates/index.html +4 -4
- package/src/dashboard/types.ts +5 -1
- package/src/exporters/api-testcase-formatter.ts +325 -0
- package/src/exporters/csv-exporter.ts +55 -27
- package/src/exporters/feature-parser.ts +8 -2
- package/src/exporters/json-exporter.ts +56 -47
- package/src/exporters/result-variants.ts +47 -0
- package/src/exporters/types.ts +7 -0
- package/src/exporters/xlsx-exporter.ts +48 -13
- package/src/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
- package/src/generators/test-generator/patterns/expect-patterns.ts +23 -0
- package/src/generators/test-generator/patterns/index.ts +21 -1
- package/src/generators/test-generator/utils/selector-resolver.ts +30 -2
- package/src/orchestrator/project-initializer.ts +120 -3
- package/src/orchestrator/templates/ai-src/commands/add-api.md +76 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/src/orchestrator/templates/ai-src/config/codex.md +3 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
- package/src/orchestrator/templates/specs-api.ts +118 -5
- package/src/orchestrator/templates/specs-test-data.ts +20 -1
package/src/dashboard/types.ts
CHANGED
|
@@ -39,8 +39,12 @@ export interface AggregateSummary {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export interface ScreenSnapshot {
|
|
42
|
-
name: string; // "kudos"
|
|
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';
|
|
44
48
|
platform?: 'web' | 'android' | 'ios' | 'mobile'; // 'mobile' = @platform:mobile screen (per-OS features below)
|
|
45
49
|
featureName: string; // name of the primary (first) feature; kept for backwards compat
|
|
46
50
|
featurePath?: string; // entry-point path of the primary feature
|
|
@@ -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
|
+
}
|
|
@@ -22,8 +22,10 @@ import {
|
|
|
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';
|
|
28
|
+
import { resolveResultVariants } from './result-variants';
|
|
27
29
|
|
|
28
30
|
export interface BuildCsvInput {
|
|
29
31
|
screen: string;
|
|
@@ -43,6 +45,18 @@ export interface BuildCsvInput {
|
|
|
43
45
|
* instead of the VI selector key "Click [VỀ KUDOS] link".
|
|
44
46
|
*/
|
|
45
47
|
selectorKeyMap?: SelectorKeyMap;
|
|
48
|
+
/**
|
|
49
|
+
* Unit kind. When `'api'`, the row builder renders the request contract
|
|
50
|
+
* (Method/API/Header/Body) into the Steps cell and the response contract
|
|
51
|
+
* (Status + Response) into the Expected cell — API scenarios otherwise leave
|
|
52
|
+
* Steps empty and Expected showing raw `{{endpoint.field}}` refs.
|
|
53
|
+
*/
|
|
54
|
+
kind?: 'screen' | 'flow' | 'api';
|
|
55
|
+
/**
|
|
56
|
+
* apis.yaml catalog (keyed by endpoint name) for api-kind units. Supplies the
|
|
57
|
+
* Method/path/headers/body/encoding the readable request block is built from.
|
|
58
|
+
*/
|
|
59
|
+
apiCatalog?: Record<string, ApiCatalogEntry>;
|
|
46
60
|
}
|
|
47
61
|
|
|
48
62
|
/**
|
|
@@ -53,21 +67,10 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
|
|
|
53
67
|
let fallbackIndex = 1;
|
|
54
68
|
|
|
55
69
|
for (const m of input.merged) {
|
|
56
|
-
// Data-driven (@cases):
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
let variants: Array<{ nameSuffix: string; result?: PlaywrightResult }> = [{ nameSuffix: '' }];
|
|
61
|
-
if (isCases && input.results) {
|
|
62
|
-
// Result titles are "<describe> > <scenario> — <label>"; match the scenario+label marker.
|
|
63
|
-
const marker = `${m.feature.name} — `;
|
|
64
|
-
const rowResults = [...input.results.entries()].filter(([t]) => t.includes(marker));
|
|
65
|
-
if (rowResults.length) {
|
|
66
|
-
variants = rowResults.map(([t, r]) => ({ nameSuffix: ` — ${t.slice(t.indexOf(marker) + marker.length)}`, result: r }));
|
|
67
|
-
}
|
|
68
|
-
} else if (input.results && m.spec) {
|
|
69
|
-
variants = [{ nameSuffix: '', result: input.results.get(m.spec.testTitle) }];
|
|
70
|
-
}
|
|
70
|
+
// Data-driven (@cases): expand to one CSV row per executed dataset input (Playwright titles
|
|
71
|
+
// "<scenario> — <label>"); plain scenarios resolve their single result. Shared with the
|
|
72
|
+
// dashboard/JSON exporter via resolveResultVariants so the two can't drift on @cases handling.
|
|
73
|
+
const variants = resolveResultVariants(m, input.results);
|
|
71
74
|
|
|
72
75
|
for (const variant of variants) {
|
|
73
76
|
const displayName = `${m.feature.name}${variant.nameSuffix}`;
|
|
@@ -97,15 +100,35 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
|
|
|
97
100
|
localize(s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text));
|
|
98
101
|
const stepLines = m.resolvedSteps.map(formatStep).filter(Boolean);
|
|
99
102
|
const expectedLines = m.resolvedExpected.map(formatStep).filter(Boolean);
|
|
100
|
-
|
|
103
|
+
let steps =
|
|
101
104
|
stepLines.length === 0 ? '' :
|
|
102
105
|
stepLines.length === 1 ? stepLines[0] :
|
|
103
106
|
stepLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
|
|
104
|
-
|
|
107
|
+
let expectedResults =
|
|
105
108
|
expectedLines.length === 0 ? '' :
|
|
106
109
|
expectedLines.length === 1 ? expectedLines[0] :
|
|
107
110
|
expectedLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
|
|
108
111
|
|
|
112
|
+
// API test cases: the scenario has no Given/When (Steps empty) and asserts
|
|
113
|
+
// against runtime response refs (`{{endpoint.status}}` — left literal since
|
|
114
|
+
// they aren't test-data keys). Render the request contract into Steps and the
|
|
115
|
+
// response contract into Expected from the @api tag + apis.yaml catalog, so
|
|
116
|
+
// QA reads Method/API/Header/Body and Status/Response instead of raw refs.
|
|
117
|
+
if (input.kind === 'api' && input.apiCatalog && hasApiCalls(m.feature.tags)) {
|
|
118
|
+
const apiRequest = formatApiRequest(m.feature.tags, input.apiCatalog, localize);
|
|
119
|
+
if (apiRequest) steps = steps ? `${steps}\n\n${apiRequest}` : apiRequest;
|
|
120
|
+
const apiExpected = formatApiExpected(m.resolvedExpected, m.feature.tags, localize);
|
|
121
|
+
if (apiExpected) expectedResults = apiExpected;
|
|
122
|
+
// Value-based redaction backstop: mask resolved credential values (from
|
|
123
|
+
// sensitively-named test-data keys) anywhere in the API cells — closes the
|
|
124
|
+
// nested-body, query-string and Expected-cell paths key-based masking misses.
|
|
125
|
+
const secrets = collectSecretValues(input.testData);
|
|
126
|
+
if (secrets.length > 0) {
|
|
127
|
+
steps = redactSecrets(steps, secrets);
|
|
128
|
+
expectedResults = redactSecrets(expectedResults, secrets);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
109
132
|
// Multi-line bullet format keeps delivery CSV/XLSX in sync with the
|
|
110
133
|
// dashboard's Test data section. Multi-line CSV cells are valid (quoted
|
|
111
134
|
// automatically) and render natively as multi-line in XLSX.
|
|
@@ -121,21 +144,26 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
|
|
|
121
144
|
let environment = '';
|
|
122
145
|
let executor = '';
|
|
123
146
|
|
|
124
|
-
if (
|
|
125
|
-
//
|
|
147
|
+
if (result) {
|
|
148
|
+
// An execution record exists → the scenario compiled AND ran; that is authoritative and
|
|
149
|
+
// MUST win over the `!m.spec` "not compiled" heuristic below. A @cases scenario compiles to
|
|
150
|
+
// per-row test titles carrying a `— <label>` suffix, so `findMatchingSpecTest` (base-name
|
|
151
|
+
// match) frequently leaves `m.spec` null even though the rows ran — without checking `result`
|
|
152
|
+
// first, those already-passed/failed rows were mislabelled N/A ("not compiled").
|
|
153
|
+
testResult = statusToTestResult(result.status);
|
|
154
|
+
executedDate = formatExecutedDate(result.startTime);
|
|
155
|
+
note = formatNote(result, expectedResults);
|
|
156
|
+
environment = formatEnv(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
|
|
157
|
+
executor = input.env.executor;
|
|
158
|
+
} else if (!m.spec) {
|
|
159
|
+
// No execution record AND no compiled test → Manual if @manual, else genuinely not compiled.
|
|
126
160
|
testResult = testcaseType === 'Manual' ? 'Pending' : 'N/A';
|
|
127
161
|
if (testcaseType !== 'Manual') {
|
|
128
162
|
note = 'Scenario not compiled — re-run `sungen generate --screen ' + input.screen + '`';
|
|
129
163
|
}
|
|
130
|
-
} else
|
|
164
|
+
} else {
|
|
131
165
|
// Compiled but no execution record → Pending
|
|
132
166
|
testResult = 'Pending';
|
|
133
|
-
} else {
|
|
134
|
-
testResult = statusToTestResult(result.status);
|
|
135
|
-
executedDate = formatExecutedDate(result.startTime);
|
|
136
|
-
note = formatNote(result, expectedResults);
|
|
137
|
-
environment = formatEnv(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
|
|
138
|
-
executor = input.env.executor;
|
|
139
167
|
}
|
|
140
168
|
|
|
141
169
|
// If we have environment info and the test actually ran, populate env + executor
|
|
@@ -144,11 +144,17 @@ export function extractTestcaseType(tags: string[]): 'Auto' | 'Manual' {
|
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
146
|
* Extract auth role from tags. Returns 'no-auth' if @no-auth, else role name or null.
|
|
147
|
+
*
|
|
148
|
+
* Merged tag arrays put feature-level tags before scenario-level ones
|
|
149
|
+
* (`parseFeatureMetadata` builds `[...featureTags, ...scenarioTags]`), so the
|
|
150
|
+
* LAST @auth: tag is the most specific — a scenario-level @auth:owner must
|
|
151
|
+
* override a feature-level @auth:user in the delivery Pre-condition column,
|
|
152
|
+
* matching the codegen precedence (getEffectiveAuthRole: Scenario > Feature).
|
|
147
153
|
*/
|
|
148
154
|
export function extractAuthRole(tags: string[]): string | null {
|
|
149
155
|
if (tags.includes('@no-auth')) return 'no-auth';
|
|
150
|
-
const
|
|
151
|
-
if (
|
|
156
|
+
const authTags = tags.filter((t) => t.startsWith('@auth:'));
|
|
157
|
+
if (authTags.length > 0) return authTags[authTags.length - 1].replace('@auth:', '');
|
|
152
158
|
return null;
|
|
153
159
|
}
|
|
154
160
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from './playwright-report-parser';
|
|
23
23
|
import { EnvironmentInfo, PlaywrightResult } from './types';
|
|
24
24
|
import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
|
|
25
|
+
import { resolveResultVariants } from './result-variants';
|
|
25
26
|
import {
|
|
26
27
|
ScenarioSnapshot,
|
|
27
28
|
ScreenSnapshot,
|
|
@@ -62,11 +63,6 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
62
63
|
let fallbackIndex = 1;
|
|
63
64
|
|
|
64
65
|
for (const m of input.merged) {
|
|
65
|
-
const { vpId, category1 } = splitVpAndName(m.feature.name);
|
|
66
|
-
const tcId = generateTcId(input.screen, vpId, fallbackIndex);
|
|
67
|
-
if (!vpId) fallbackIndex++;
|
|
68
|
-
|
|
69
|
-
const category2 = mapVpToCategory2(vpId, category1);
|
|
70
66
|
const priority = extractPriority(m.feature.tags) as ScenarioSnapshot['priority'];
|
|
71
67
|
const baseType = extractTestcaseType(m.feature.tags);
|
|
72
68
|
const authRole = extractAuthRole(m.feature.tags);
|
|
@@ -87,27 +83,32 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
87
83
|
// key/value per line instead of the CSV-friendly "; " join.
|
|
88
84
|
const testDataStr = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
|
|
89
85
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
// @cases scenarios expand to one snapshot entry per executed dataset row (shared with the CSV
|
|
87
|
+
// exporter via resolveResultVariants), so their per-row pass/fail is counted — not collapsed
|
|
88
|
+
// into one Pending/N/A entry as before.
|
|
89
|
+
const variants = resolveResultVariants(m, input.results ?? null);
|
|
90
|
+
for (const variant of variants) {
|
|
91
|
+
const displayName = `${m.feature.name}${variant.nameSuffix}`;
|
|
92
|
+
const { vpId, category1 } = splitVpAndName(displayName);
|
|
93
|
+
const tcId = generateTcId(input.screen, vpId, fallbackIndex);
|
|
94
|
+
if (!vpId) fallbackIndex++;
|
|
95
|
+
const category2 = mapVpToCategory2(vpId, category1);
|
|
96
|
+
const result = variant.result;
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
let status: ScenarioSnapshot['status'];
|
|
99
|
+
let executedDate: string | undefined;
|
|
100
|
+
let errorMessage: string | undefined;
|
|
101
|
+
let tracePath: string | undefined;
|
|
102
|
+
let testEnvironment: string | undefined;
|
|
103
|
+
let testExecutor: string | undefined;
|
|
104
|
+
let type: ScenarioSnapshot['type'];
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
status = 'Pending';
|
|
110
|
-
} else {
|
|
106
|
+
if (result) {
|
|
107
|
+
// A result exists → the scenario compiled AND ran; authoritative over the `!m.spec`
|
|
108
|
+
// "not compiled" heuristic. A @cases test title carries a `— <label>` suffix, so
|
|
109
|
+
// findMatchingSpecTest (base-name match) often leaves m.spec null even though rows ran —
|
|
110
|
+
// without checking `result` first, those passed/failed rows were mislabelled N/A / Pending.
|
|
111
|
+
type = baseType;
|
|
111
112
|
status = statusToTestResult(result.status) as ScenarioSnapshot['status'];
|
|
112
113
|
executedDate = formatExecutedDate(result.startTime);
|
|
113
114
|
if (result.error) {
|
|
@@ -126,31 +127,39 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
126
127
|
if (result.tracePath) tracePath = result.tracePath;
|
|
127
128
|
testEnvironment = formatTestEnvironment(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
|
|
128
129
|
testExecutor = input.env.executor;
|
|
130
|
+
} else if (!m.spec) {
|
|
131
|
+
// No result AND no compiled test → Manual if @manual, else genuinely not compiled.
|
|
132
|
+
type = baseType === 'Manual' ? 'Manual' : 'Not compiled';
|
|
133
|
+
status = baseType === 'Manual' ? 'Pending' : 'N/A';
|
|
134
|
+
} else {
|
|
135
|
+
// Compiled but no execution record → Pending.
|
|
136
|
+
type = baseType;
|
|
137
|
+
status = 'Pending';
|
|
129
138
|
}
|
|
130
|
-
}
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
140
|
+
scenarios.push({
|
|
141
|
+
tcId,
|
|
142
|
+
category1,
|
|
143
|
+
category2,
|
|
144
|
+
category3: input.featureName,
|
|
145
|
+
category4: input.screen,
|
|
146
|
+
featureBaseName: input.featureBaseName ?? input.screen,
|
|
147
|
+
vpId,
|
|
148
|
+
priority,
|
|
149
|
+
type,
|
|
150
|
+
status,
|
|
151
|
+
tags: m.feature.tags,
|
|
152
|
+
precondition,
|
|
153
|
+
testData: testDataStr,
|
|
154
|
+
steps,
|
|
155
|
+
expectedResults,
|
|
156
|
+
executedDate,
|
|
157
|
+
errorMessage,
|
|
158
|
+
tracePath,
|
|
159
|
+
testEnvironment,
|
|
160
|
+
testExecutor,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
return {
|