@sun-asterisk/sungen 2.6.7 → 2.6.10
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.d.ts +2 -1
- package/dist/cli/commands/dashboard.d.ts.map +1 -1
- package/dist/cli/commands/dashboard.js +9 -9
- 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 +33 -0
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/history-store.d.ts +13 -9
- package/dist/dashboard/history-store.d.ts.map +1 -1
- package/dist/dashboard/history-store.js +19 -28
- package/dist/dashboard/history-store.js.map +1 -1
- package/dist/dashboard/html-renderer.d.ts +1 -1
- package/dist/dashboard/html-renderer.d.ts.map +1 -1
- package/dist/dashboard/html-renderer.js +2 -2
- package/dist/dashboard/html-renderer.js.map +1 -1
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
- package/dist/dashboard/snapshot-builder.js +38 -2
- package/dist/dashboard/snapshot-builder.js.map +1 -1
- package/dist/dashboard/templates/index.html +142 -221
- package/dist/exporters/csv-exporter.d.ts +4 -0
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +35 -26
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +16 -4
- 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 +28 -20
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +22 -5
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/scenario-merger.d.ts +23 -1
- package/dist/exporters/scenario-merger.d.ts.map +1 -1
- package/dist/exporters/scenario-merger.js +39 -0
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/step-formatter.d.ts +31 -3
- package/dist/exporters/step-formatter.d.ts.map +1 -1
- package/dist/exporters/step-formatter.js +52 -19
- package/dist/exporters/step-formatter.js.map +1 -1
- package/dist/exporters/sun-logo.d.ts +10 -0
- package/dist/exporters/sun-logo.d.ts.map +1 -0
- package/dist/exporters/sun-logo.js +13 -0
- package/dist/exporters/sun-logo.js.map +1 -0
- package/dist/exporters/test-data-resolver.d.ts +13 -5
- package/dist/exporters/test-data-resolver.d.ts.map +1 -1
- package/dist/exporters/test-data-resolver.js +36 -14
- package/dist/exporters/test-data-resolver.js.map +1 -1
- package/dist/exporters/types.d.ts +16 -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 +169 -99
- package/dist/exporters/xlsx-exporter.js.map +1 -1
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +1 -1
- package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +154 -67
- package/dist/orchestrator/templates/ai-instructions/claude-skill-test-design-techniques.md +39 -0
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +1 -1
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +134 -61
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-test-design-techniques.md +39 -0
- package/dist/orchestrator/templates/playwright.config.d.ts.map +1 -1
- package/dist/orchestrator/templates/playwright.config.js +2 -0
- package/dist/orchestrator/templates/playwright.config.js.map +1 -1
- package/dist/orchestrator/templates/playwright.config.ts +2 -0
- package/dist/orchestrator/templates/specs-base.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-base.js +1 -5
- package/dist/orchestrator/templates/specs-base.js.map +1 -1
- package/dist/orchestrator/templates/specs-base.ts +1 -5
- package/package.json +1 -1
- package/src/cli/commands/dashboard.ts +9 -9
- package/src/cli/commands/delivery.ts +30 -0
- package/src/cli/index.ts +1 -1
- package/src/dashboard/history-store.ts +22 -28
- package/src/dashboard/html-renderer.ts +6 -2
- package/src/dashboard/snapshot-builder.ts +36 -2
- package/src/dashboard/templates/index.html +142 -221
- package/src/dashboard/types.ts +1 -1
- package/src/exporters/csv-exporter.ts +44 -27
- package/src/exporters/feature-parser.ts +27 -8
- package/src/exporters/json-exporter.ts +31 -21
- package/src/exporters/playwright-report-parser.ts +23 -5
- package/src/exporters/scenario-merger.ts +65 -1
- package/src/exporters/step-formatter.ts +48 -23
- package/src/exporters/sun-logo.ts +10 -0
- package/src/exporters/test-data-resolver.ts +37 -13
- package/src/exporters/types.ts +18 -1
- package/src/exporters/xlsx-exporter.ts +176 -101
- package/src/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +1 -1
- package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +154 -67
- package/src/orchestrator/templates/ai-instructions/claude-skill-test-design-techniques.md +39 -0
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +1 -1
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +134 -61
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-test-design-techniques.md +39 -0
- package/src/orchestrator/templates/playwright.config.ts +2 -0
- package/src/orchestrator/templates/specs-base.ts +1 -5
package/src/dashboard/types.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface AggregateSummary {
|
|
|
33
33
|
na: number;
|
|
34
34
|
notCompiled: number;
|
|
35
35
|
passRate: number; // 0..1, only counting executed (passed+failed)
|
|
36
|
-
byPriority: Record<string, number>; //
|
|
36
|
+
byPriority: Record<string, number>; // High/Normal/Low
|
|
37
37
|
byCategory: Record<string, number>; // Accessing/GUI/Function
|
|
38
38
|
byType: Record<string, number>; // Auto/Manual/Not compiled
|
|
39
39
|
}
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
mapVpToCategory2,
|
|
16
16
|
splitVpAndName,
|
|
17
17
|
} from './feature-parser';
|
|
18
|
-
import {
|
|
18
|
+
import { formatFeatureSteps, formatPrecondition, keepActor, stripActor } from './step-formatter';
|
|
19
19
|
import { formatTestData } from './test-data-resolver';
|
|
20
20
|
import {
|
|
21
21
|
formatExecutedDate,
|
|
@@ -27,6 +27,10 @@ import { EnvironmentInfo, PlaywrightResult, ScreenSummary, TestCaseRow } from '.
|
|
|
27
27
|
export interface BuildCsvInput {
|
|
28
28
|
screen: string;
|
|
29
29
|
featureName: string;
|
|
30
|
+
/** URL path for the screen/flow entry point — concatenated onto baseURL
|
|
31
|
+
* when building the Test Environment column so QA can see the exact URL
|
|
32
|
+
* the test targets. */
|
|
33
|
+
featurePath?: string;
|
|
30
34
|
merged: MergedScenario[];
|
|
31
35
|
testData: Record<string, string>;
|
|
32
36
|
results: Map<string, PlaywrightResult> | null;
|
|
@@ -50,23 +54,26 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
|
|
|
50
54
|
const testcaseType = extractTestcaseType(m.feature.tags);
|
|
51
55
|
const authRole = extractAuthRole(m.feature.tags);
|
|
52
56
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
// Pre-condition is auth-only. Steps section = Background + @extend base
|
|
58
|
+
// (entire) + own pre-Then. Expected = own's Then-onwards only.
|
|
59
|
+
const precondition = formatPrecondition(authRole);
|
|
60
|
+
const formatStep = (s: { text: string; bucket: 'given' | 'when' | 'then' }) =>
|
|
61
|
+
s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text);
|
|
62
|
+
const stepLines = m.resolvedSteps.map(formatStep).filter(Boolean);
|
|
63
|
+
const expectedLines = m.resolvedExpected.map(formatStep).filter(Boolean);
|
|
64
|
+
const steps =
|
|
65
|
+
stepLines.length === 0 ? '' :
|
|
66
|
+
stepLines.length === 1 ? stepLines[0] :
|
|
67
|
+
stepLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
|
|
68
|
+
const expectedResults =
|
|
69
|
+
expectedLines.length === 0 ? '' :
|
|
70
|
+
expectedLines.length === 1 ? expectedLines[0] :
|
|
71
|
+
expectedLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} else {
|
|
63
|
-
// Fallback to .feature raw steps
|
|
64
|
-
precondition = formatPrecondition(authRole, m.feature.rawGivenSteps);
|
|
65
|
-
steps = formatNumberedSteps(m.feature.rawWhenSteps);
|
|
66
|
-
expectedResults = formatNumberedSteps(m.feature.rawThenSteps);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const testData = formatTestData(m.feature.referencedVars, input.testData);
|
|
73
|
+
// Multi-line bullet format keeps delivery CSV/XLSX in sync with the
|
|
74
|
+
// dashboard's Test data section. Multi-line CSV cells are valid (quoted
|
|
75
|
+
// automatically) and render natively as multi-line in XLSX.
|
|
76
|
+
const testData = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
|
|
70
77
|
|
|
71
78
|
// Match Playwright result by test title (from .spec.ts) OR by scenarioName
|
|
72
79
|
let result: PlaywrightResult | undefined;
|
|
@@ -94,13 +101,13 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
|
|
|
94
101
|
testResult = statusToTestResult(result.status);
|
|
95
102
|
executedDate = formatExecutedDate(result.startTime);
|
|
96
103
|
note = formatNote(result);
|
|
97
|
-
environment =
|
|
104
|
+
environment = formatEnv(input.env.baseURL, input.featurePath, input.env.projectName);
|
|
98
105
|
executor = input.env.executor;
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
// If we have environment info and the test actually ran, populate env + executor
|
|
102
109
|
if (testResult !== 'Pending' && testResult !== 'N/A') {
|
|
103
|
-
if (!environment) environment =
|
|
110
|
+
if (!environment) environment = formatEnv(input.env.baseURL, input.featurePath, input.env.projectName);
|
|
104
111
|
if (!executor) executor = input.env.executor;
|
|
105
112
|
}
|
|
106
113
|
|
|
@@ -159,6 +166,18 @@ export function buildSummary(screen: string, rows: TestCaseRow[], outputFile: st
|
|
|
159
166
|
* Escape a CSV cell per RFC 4180.
|
|
160
167
|
* Wrap in quotes if contains: comma, newline, quote. Escape embedded " as "".
|
|
161
168
|
*/
|
|
169
|
+
/**
|
|
170
|
+
* Compose the "Environment" cell. Concatenates the entry-point path onto
|
|
171
|
+
* the baseURL so the deliverable shows the exact URL each test targets
|
|
172
|
+
* (e.g. `https://staging.example.com/kudos (chromium)`).
|
|
173
|
+
*/
|
|
174
|
+
function formatEnv(baseURL: string, featurePath: string | undefined, projectName: string): string {
|
|
175
|
+
const base = (baseURL || '').replace(/\/+$/, '');
|
|
176
|
+
const p = (featurePath || '').replace(/^\/+/, '');
|
|
177
|
+
const url = p ? `${base}/${p}` : base;
|
|
178
|
+
return `${url} (${projectName})`;
|
|
179
|
+
}
|
|
180
|
+
|
|
162
181
|
function csvCell(v: string | number | undefined | null): string {
|
|
163
182
|
if (v === null || v === undefined) return '';
|
|
164
183
|
const s = String(v);
|
|
@@ -204,10 +223,10 @@ export function renderCsv(summary: ScreenSummary, rows: TestCaseRow[], specLink:
|
|
|
204
223
|
lines.push(csvRow(['*: Mandatory', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '']));
|
|
205
224
|
lines.push(csvRow([
|
|
206
225
|
'TC ID*',
|
|
207
|
-
'
|
|
208
|
-
'
|
|
209
|
-
'
|
|
210
|
-
'
|
|
226
|
+
'Screen/Function',
|
|
227
|
+
'Big item',
|
|
228
|
+
'Medium item',
|
|
229
|
+
'Test Object',
|
|
211
230
|
'Pre-condition',
|
|
212
231
|
'Test Data',
|
|
213
232
|
'Steps*',
|
|
@@ -239,10 +258,10 @@ export function renderCsv(summary: ScreenSummary, rows: TestCaseRow[], specLink:
|
|
|
239
258
|
for (const row of groupRows) {
|
|
240
259
|
lines.push(csvRow([
|
|
241
260
|
row.tcId,
|
|
242
|
-
row.category1,
|
|
243
261
|
'',
|
|
244
262
|
'',
|
|
245
263
|
'',
|
|
264
|
+
row.category1,
|
|
246
265
|
row.precondition,
|
|
247
266
|
row.testData,
|
|
248
267
|
row.steps,
|
|
@@ -264,10 +283,10 @@ export function renderCsv(summary: ScreenSummary, rows: TestCaseRow[], specLink:
|
|
|
264
283
|
for (const row of groupRows) {
|
|
265
284
|
lines.push(csvRow([
|
|
266
285
|
row.tcId,
|
|
267
|
-
row.category1,
|
|
268
286
|
'',
|
|
269
287
|
'',
|
|
270
288
|
'',
|
|
289
|
+
row.category1,
|
|
271
290
|
row.precondition,
|
|
272
291
|
row.testData,
|
|
273
292
|
row.steps,
|
|
@@ -300,5 +319,3 @@ export function writeCsv(cwd: string, screen: string, csvContent: string): strin
|
|
|
300
319
|
return outPath;
|
|
301
320
|
}
|
|
302
321
|
|
|
303
|
-
// mark unused import to silence TS if needed
|
|
304
|
-
void cleanStepLine;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { GherkinParser, ParsedFeature, ParsedScenario } from '../generators/gherkin-parser';
|
|
7
|
-
import { FeatureMetadata, ScenarioMetadata } from './types';
|
|
7
|
+
import { FeatureMetadata, OrderedStep, ScenarioMetadata } from './types';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Variables referenced in a scenario: find all {{var_name}} in step text.
|
|
@@ -12,7 +12,7 @@ import { FeatureMetadata, ScenarioMetadata } from './types';
|
|
|
12
12
|
function extractReferencedVars(scenario: ParsedScenario): string[] {
|
|
13
13
|
const vars = new Set<string>();
|
|
14
14
|
for (const step of scenario.steps) {
|
|
15
|
-
const matches = step.text.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g);
|
|
15
|
+
const matches = step.text.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_.-]*)\}\}/g);
|
|
16
16
|
for (const match of matches) {
|
|
17
17
|
vars.add(match[1]);
|
|
18
18
|
}
|
|
@@ -20,7 +20,7 @@ function extractReferencedVars(scenario: ParsedScenario): string[] {
|
|
|
20
20
|
if (step.dataTable) {
|
|
21
21
|
for (const row of step.dataTable.rows) {
|
|
22
22
|
for (const cell of row.cells) {
|
|
23
|
-
const cellMatches = cell.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g);
|
|
23
|
+
const cellMatches = cell.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_.-]*)\}\}/g);
|
|
24
24
|
for (const match of cellMatches) {
|
|
25
25
|
vars.add(match[1]);
|
|
26
26
|
}
|
|
@@ -35,10 +35,16 @@ function extractReferencedVars(scenario: ParsedScenario): string[] {
|
|
|
35
35
|
* Classify each step into Given / When / Then bucket based on its preceding
|
|
36
36
|
* explicit keyword. "And" inherits from the previous explicit keyword.
|
|
37
37
|
*/
|
|
38
|
-
function classifySteps(scenario: ParsedScenario): {
|
|
38
|
+
function classifySteps(scenario: ParsedScenario): {
|
|
39
|
+
given: string[];
|
|
40
|
+
when: string[];
|
|
41
|
+
then: string[];
|
|
42
|
+
ordered: OrderedStep[];
|
|
43
|
+
} {
|
|
39
44
|
const given: string[] = [];
|
|
40
45
|
const when: string[] = [];
|
|
41
46
|
const then: string[] = [];
|
|
47
|
+
const ordered: OrderedStep[] = [];
|
|
42
48
|
let currentBucket: 'given' | 'when' | 'then' = 'given';
|
|
43
49
|
|
|
44
50
|
for (const step of scenario.steps) {
|
|
@@ -51,9 +57,11 @@ function classifySteps(scenario: ParsedScenario): { given: string[]; when: strin
|
|
|
51
57
|
if (currentBucket === 'given') given.push(step.text);
|
|
52
58
|
else if (currentBucket === 'when') when.push(step.text);
|
|
53
59
|
else then.push(step.text);
|
|
60
|
+
|
|
61
|
+
ordered.push({ text: step.text, bucket: currentBucket });
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
return { given, when, then };
|
|
64
|
+
return { given, when, then, ordered };
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
/**
|
|
@@ -63,8 +71,14 @@ export function parseFeatureMetadata(featureFilePath: string): FeatureMetadata {
|
|
|
63
71
|
const parser = new GherkinParser();
|
|
64
72
|
const parsed: ParsedFeature = parser.parseFeatureFile(featureFilePath);
|
|
65
73
|
|
|
74
|
+
// Background block — classify once and surface on FeatureMetadata so the
|
|
75
|
+
// merger can prepend it to each runnable scenario without re-parsing.
|
|
76
|
+
const bg = parsed.background
|
|
77
|
+
? classifySteps(parsed.background)
|
|
78
|
+
: { given: [], when: [], then: [], ordered: [] as OrderedStep[] };
|
|
79
|
+
|
|
66
80
|
const scenarios: ScenarioMetadata[] = parsed.scenarios.map((sc) => {
|
|
67
|
-
const { given, when, then } = classifySteps(sc);
|
|
81
|
+
const { given, when, then, ordered } = classifySteps(sc);
|
|
68
82
|
|
|
69
83
|
return {
|
|
70
84
|
name: sc.name,
|
|
@@ -73,8 +87,9 @@ export function parseFeatureMetadata(featureFilePath: string): FeatureMetadata {
|
|
|
73
87
|
extendsName: sc.extendsName,
|
|
74
88
|
referencedVars: extractReferencedVars(sc),
|
|
75
89
|
rawGivenSteps: given,
|
|
76
|
-
rawWhenSteps:
|
|
77
|
-
rawThenSteps:
|
|
90
|
+
rawWhenSteps: when,
|
|
91
|
+
rawThenSteps: then,
|
|
92
|
+
orderedSteps: ordered,
|
|
78
93
|
};
|
|
79
94
|
});
|
|
80
95
|
|
|
@@ -82,6 +97,10 @@ export function parseFeatureMetadata(featureFilePath: string): FeatureMetadata {
|
|
|
82
97
|
featureName: parsed.name,
|
|
83
98
|
featurePath: parsed.path,
|
|
84
99
|
featureTags: parsed.tags,
|
|
100
|
+
backgroundGivenSteps: bg.given,
|
|
101
|
+
backgroundWhenSteps: bg.when,
|
|
102
|
+
backgroundThenSteps: bg.then,
|
|
103
|
+
backgroundOrderedSteps: bg.ordered,
|
|
85
104
|
scenarios,
|
|
86
105
|
};
|
|
87
106
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
mapVpToCategory2,
|
|
15
15
|
splitVpAndName,
|
|
16
16
|
} from './feature-parser';
|
|
17
|
-
import { formatPrecondition,
|
|
17
|
+
import { formatPrecondition, keepActor, stripActor } from './step-formatter';
|
|
18
18
|
import { formatTestData } from './test-data-resolver';
|
|
19
19
|
import {
|
|
20
20
|
formatExecutedDate,
|
|
@@ -56,24 +56,17 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
56
56
|
const baseType = extractTestcaseType(m.feature.tags);
|
|
57
57
|
const authRole = extractAuthRole(m.feature.tags);
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
expectedRaw = m.feature.rawThenSteps;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const precondition = formatPrecondition(authRole, givenForPrecondition);
|
|
74
|
-
const steps = stepsRaw.map(cleanStepLine).filter(Boolean);
|
|
75
|
-
const expectedResults = expectedRaw.map(cleanStepLine).filter(Boolean);
|
|
76
|
-
const testDataStr = formatTestData(m.feature.referencedVars, input.testData);
|
|
59
|
+
// Pre-condition is auth-only. Steps section = Background + @extend base
|
|
60
|
+
// (all of it, including its Then) + own pre-Then. Expected = own's
|
|
61
|
+
// Then-onwards only. Given → keepActor (state), When/Then → stripActor.
|
|
62
|
+
const precondition = formatPrecondition(authRole);
|
|
63
|
+
const formatStep = (s: { text: string; bucket: 'given' | 'when' | 'then' }) =>
|
|
64
|
+
s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text);
|
|
65
|
+
const steps = m.resolvedSteps.map(formatStep).filter(Boolean);
|
|
66
|
+
const expectedResults = m.resolvedExpected.map(formatStep).filter(Boolean);
|
|
67
|
+
// Dashboard modal uses whitespace-pre-wrap — pass '\n' to render one
|
|
68
|
+
// key/value per line instead of the CSV-friendly "; " join.
|
|
69
|
+
const testDataStr = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
|
|
77
70
|
|
|
78
71
|
let result: PlaywrightResult | undefined;
|
|
79
72
|
if (input.results && m.spec) {
|
|
@@ -98,9 +91,13 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
98
91
|
} else {
|
|
99
92
|
status = statusToTestResult(result.status) as ScenarioSnapshot['status'];
|
|
100
93
|
executedDate = formatExecutedDate(result.startTime);
|
|
101
|
-
if (result.error)
|
|
94
|
+
if (result.error) {
|
|
95
|
+
// Strip a leading "Error:" so consumers (dashboard / CSV `Note`)
|
|
96
|
+
// can prepend their own label without ending up with `Error: Error:`.
|
|
97
|
+
errorMessage = String(result.error).split('\n')[0].trim().replace(/^Error:\s*/i, '');
|
|
98
|
+
}
|
|
102
99
|
if (result.tracePath) tracePath = result.tracePath;
|
|
103
|
-
testEnvironment =
|
|
100
|
+
testEnvironment = formatTestEnvironment(input.env.baseURL, input.featurePath, input.env.projectName);
|
|
104
101
|
testExecutor = input.env.executor;
|
|
105
102
|
}
|
|
106
103
|
}
|
|
@@ -139,6 +136,19 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
139
136
|
};
|
|
140
137
|
}
|
|
141
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Compose the "Environment" string with the screen/flow entry-point path
|
|
141
|
+
* concatenated to the baseURL. Strips trailing `/` from baseURL and leading
|
|
142
|
+
* `/` from path to avoid `//` when joining. Falls back to baseURL only when
|
|
143
|
+
* the path is missing.
|
|
144
|
+
*/
|
|
145
|
+
function formatTestEnvironment(baseURL: string, featurePath: string | undefined, projectName: string): string {
|
|
146
|
+
const base = (baseURL || '').replace(/\/+$/, '');
|
|
147
|
+
const p = (featurePath || '').replace(/^\/+/, '');
|
|
148
|
+
const url = p ? `${base}/${p}` : base;
|
|
149
|
+
return `${url} (${projectName})`;
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
function summarizeScreen(scenarios: ScenarioSnapshot[]): ScreenSummaryStats {
|
|
143
153
|
const stats: ScreenSummaryStats = {
|
|
144
154
|
total: scenarios.length,
|
|
@@ -80,7 +80,7 @@ export function loadPlaywrightReport(reportPath: string): Map<string, Playwright
|
|
|
80
80
|
const results = test?.results || [];
|
|
81
81
|
const res = results[results.length - 1];
|
|
82
82
|
const status = normalizeStatus(res?.status);
|
|
83
|
-
const errorMsg = res?.error?.message || '';
|
|
83
|
+
const errorMsg = stripAnsi(res?.error?.message || '');
|
|
84
84
|
const trace = res?.attachments?.find((a) => a.name === 'trace' || a.contentType === 'application/zip')?.path;
|
|
85
85
|
|
|
86
86
|
// Strip the leading file-path suite title if present.
|
|
@@ -99,6 +99,18 @@ export function loadPlaywrightReport(reportPath: string): Map<string, Playwright
|
|
|
99
99
|
return result;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Strip ANSI escape sequences (terminal color/style codes) that Playwright
|
|
104
|
+
* embeds in error messages. The dashboard and CSV render plain text, so
|
|
105
|
+
* codes like [31m and [39m must be removed.
|
|
106
|
+
*/
|
|
107
|
+
function stripAnsi(s: string): string {
|
|
108
|
+
// CSI sequences: ESC '[' params final-byte. Covers SGR colors plus most
|
|
109
|
+
// other cursor/erase codes Playwright might emit.
|
|
110
|
+
// eslint-disable-next-line no-control-regex
|
|
111
|
+
return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '');
|
|
112
|
+
}
|
|
113
|
+
|
|
102
114
|
/**
|
|
103
115
|
* Normalize playwright status strings to our enum.
|
|
104
116
|
*/
|
|
@@ -166,10 +178,16 @@ export function formatExecutedDate(startTime: string | undefined): string {
|
|
|
166
178
|
export function formatNote(result: PlaywrightResult | undefined): string {
|
|
167
179
|
if (!result) return '';
|
|
168
180
|
if (result.status !== 'failed' && result.status !== 'timedOut') return '';
|
|
169
|
-
|
|
181
|
+
// Some Playwright error messages already start with `Error:` — strip it
|
|
182
|
+
// so we don't end up rendering `Error: Error: …` once we prepend the label.
|
|
183
|
+
const firstLine = (result.error || '').split('\n')[0].trim().replace(/^Error:\s*/i, '');
|
|
170
184
|
const truncated = firstLine.length > 200 ? firstLine.substring(0, 197) + '…' : firstLine;
|
|
171
185
|
const parts: string[] = [];
|
|
172
|
-
if (truncated)
|
|
173
|
-
if (result.tracePath) parts.push(
|
|
174
|
-
|
|
186
|
+
if (truncated) parts.push(`• Error: ${truncated}`);
|
|
187
|
+
if (result.tracePath) parts.push(`• Trace: ${result.tracePath}`);
|
|
188
|
+
// Newline-separated bullets so each field lands on its own row when the
|
|
189
|
+
// cell wraps — matches the Test Data column's `• key: value` formatting.
|
|
190
|
+
// Excel/CSV honour `\n` inside quoted cells; the dashboard modal already
|
|
191
|
+
// renders this with whitespace-pre-wrap.
|
|
192
|
+
return parts.join('\n');
|
|
175
193
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Scenarios in .feature but NOT in .spec.ts → marked "Not compiled"
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { FeatureMetadata, ScenarioMetadata, SpecFileData, SpecTest } from './types';
|
|
11
|
+
import { FeatureMetadata, OrderedStep, ScenarioMetadata, SpecFileData, SpecTest } from './types';
|
|
12
12
|
import { isStepsBaseScenario, isSampleScaffoldScenario } from './feature-parser';
|
|
13
13
|
|
|
14
14
|
export interface MergedScenario {
|
|
@@ -17,6 +17,29 @@ export interface MergedScenario {
|
|
|
17
17
|
|
|
18
18
|
/** Matched compiled test from .spec.ts, or null if not compiled. */
|
|
19
19
|
spec: SpecTest | null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* When/Then/Given steps with any `@extend:<name>` chain pre-resolved
|
|
23
|
+
* against the matching `@steps:<name>` base scenario (its steps are
|
|
24
|
+
* prepended). Use these for surfaces that show the test case text
|
|
25
|
+
* (dashboard Steps section) so we get the canonical feature wording
|
|
26
|
+
* without re-parsing compiled spec.ts comments.
|
|
27
|
+
*/
|
|
28
|
+
resolvedWhenSteps: string[];
|
|
29
|
+
resolvedThenSteps: string[];
|
|
30
|
+
resolvedGivenSteps: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Steps section for display, in chronological order:
|
|
33
|
+
* Background (all) → @extend base (all incl. its Then) → own up to the
|
|
34
|
+
* first `Then`. Base's `Then` stays here because it's mid-flow setup,
|
|
35
|
+
* not the scenario's final assertion.
|
|
36
|
+
*/
|
|
37
|
+
resolvedSteps: OrderedStep[];
|
|
38
|
+
/**
|
|
39
|
+
* Expected results — own scenario's first `Then` onwards. Empty when the
|
|
40
|
+
* scenario has no `Then`.
|
|
41
|
+
*/
|
|
42
|
+
resolvedExpected: OrderedStep[];
|
|
20
43
|
}
|
|
21
44
|
|
|
22
45
|
/**
|
|
@@ -28,14 +51,55 @@ export function mergeFeatureAndSpec(
|
|
|
28
51
|
): MergedScenario[] {
|
|
29
52
|
const result: MergedScenario[] = [];
|
|
30
53
|
|
|
54
|
+
// Index @steps base scenarios so @extend can resolve them.
|
|
55
|
+
const stepsBaseByName = new Map<string, ScenarioMetadata>();
|
|
56
|
+
for (const sc of feature.scenarios) {
|
|
57
|
+
if (sc.stepsName) stepsBaseByName.set(sc.stepsName, sc);
|
|
58
|
+
}
|
|
59
|
+
|
|
31
60
|
for (const scenario of feature.scenarios) {
|
|
32
61
|
if (isStepsBaseScenario(scenario)) continue; // skip @steps base scenarios
|
|
33
62
|
if (isSampleScaffoldScenario(scenario)) continue; // skip default sample
|
|
34
63
|
|
|
35
64
|
const specTest = findMatchingSpecTest(scenario, spec.tests);
|
|
65
|
+
const base = scenario.extendsName ? stepsBaseByName.get(scenario.extendsName) : undefined;
|
|
66
|
+
|
|
67
|
+
// Order of execution for a runnable scenario:
|
|
68
|
+
// 1. Feature Background (once) — all buckets are setup, stay in Steps.
|
|
69
|
+
// 2. @extend base scenario's own steps (if any) — *including* its Then,
|
|
70
|
+
// because the base's Then is a mid-flow assertion, not the scenario's
|
|
71
|
+
// final verification.
|
|
72
|
+
// 3. This scenario's own steps in original sequence, split at the
|
|
73
|
+
// first `Then`: pre-Then → Steps, Then-onwards → Expected results.
|
|
74
|
+
const ownOrdered = scenario.orderedSteps;
|
|
75
|
+
const firstThenIdx = ownOrdered.findIndex((s) => s.bucket === 'then');
|
|
76
|
+
const ownPreThen = firstThenIdx >= 0 ? ownOrdered.slice(0, firstThenIdx) : ownOrdered;
|
|
77
|
+
const ownPostThen = firstThenIdx >= 0 ? ownOrdered.slice(firstThenIdx) : [];
|
|
78
|
+
|
|
36
79
|
result.push({
|
|
37
80
|
feature: scenario,
|
|
38
81
|
spec: specTest,
|
|
82
|
+
resolvedWhenSteps: [
|
|
83
|
+
...feature.backgroundWhenSteps,
|
|
84
|
+
...(base?.rawWhenSteps ?? []),
|
|
85
|
+
...scenario.rawWhenSteps,
|
|
86
|
+
],
|
|
87
|
+
resolvedThenSteps: [
|
|
88
|
+
...feature.backgroundThenSteps,
|
|
89
|
+
...(base?.rawThenSteps ?? []),
|
|
90
|
+
...scenario.rawThenSteps,
|
|
91
|
+
],
|
|
92
|
+
resolvedGivenSteps: [
|
|
93
|
+
...feature.backgroundGivenSteps,
|
|
94
|
+
...(base?.rawGivenSteps ?? []),
|
|
95
|
+
...scenario.rawGivenSteps,
|
|
96
|
+
],
|
|
97
|
+
resolvedSteps: [
|
|
98
|
+
...feature.backgroundOrderedSteps,
|
|
99
|
+
...(base?.orderedSteps ?? []),
|
|
100
|
+
...ownPreThen,
|
|
101
|
+
],
|
|
102
|
+
resolvedExpected: ownPostThen,
|
|
39
103
|
});
|
|
40
104
|
}
|
|
41
105
|
|
|
@@ -20,6 +20,41 @@ export function formatNumberedSteps(lines: string[]): string {
|
|
|
20
20
|
return cleaned.map((line, idx) => `${idx + 1}. ${line}`).join('\n');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Strip the leading actor (`User `) from a raw Gherkin step and capitalise
|
|
25
|
+
* the first letter. Brackets `[ref]`, `{{var}}` placeholders and element
|
|
26
|
+
* types are kept verbatim. Use for When/Then steps where "User click X"
|
|
27
|
+
* reads more naturally as "Click X".
|
|
28
|
+
*/
|
|
29
|
+
export function stripActor(raw: string): string {
|
|
30
|
+
const out = raw.replace(/^\s*User\s+/i, '').trim();
|
|
31
|
+
return out.length > 0 ? out.charAt(0).toUpperCase() + out.slice(1) : out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Capitalise the first letter of a raw Gherkin step but keep the "User"
|
|
36
|
+
* actor. Use for Given/Background steps where "User is on X" reads more
|
|
37
|
+
* naturally than "Is on X".
|
|
38
|
+
*/
|
|
39
|
+
export function keepActor(raw: string): string {
|
|
40
|
+
const out = raw.trim();
|
|
41
|
+
return out.length > 0 ? out.charAt(0).toUpperCase() + out.slice(1) : out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Number a list of raw feature steps. Mirrors `formatNumberedSteps` but
|
|
46
|
+
* preserves the original Gherkin punctuation (no bracket-to-quote conversion).
|
|
47
|
+
* The mapper picks between `stripActor` (When/Then) and `keepActor`
|
|
48
|
+
* (Given) so the actor word matches the step's grammatical role.
|
|
49
|
+
* Use this for feature-direct rendering (dashboard + delivery).
|
|
50
|
+
*/
|
|
51
|
+
export function formatFeatureSteps(lines: string[], mapper: (s: string) => string = stripActor): string {
|
|
52
|
+
const cleaned = lines.map(mapper).filter((l) => l.length > 0);
|
|
53
|
+
if (cleaned.length === 0) return '';
|
|
54
|
+
if (cleaned.length === 1) return cleaned[0];
|
|
55
|
+
return cleaned.map((line, idx) => `${idx + 1}. ${line}`).join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
/**
|
|
24
59
|
* Clean a raw step line (either Gherkin text or .spec.ts comment).
|
|
25
60
|
*
|
|
@@ -53,28 +88,18 @@ export function cleanStepLine(raw: string): string {
|
|
|
53
88
|
}
|
|
54
89
|
|
|
55
90
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
91
|
+
* Pre-condition — login/auth context only.
|
|
92
|
+
*
|
|
93
|
+
* `@auth:user` → `Logged in as user`
|
|
94
|
+
* `@no-auth` → `Not authenticated`
|
|
95
|
+
* (no auth tag) → empty string
|
|
96
|
+
*
|
|
97
|
+
* Background and Given steps are no longer echoed here — they're rendered as
|
|
98
|
+
* the first entries in the Steps list so the chain of actions reads as one
|
|
99
|
+
* uninterrupted sequence.
|
|
58
100
|
*/
|
|
59
|
-
export function formatPrecondition(
|
|
60
|
-
authRole
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const parts: string[] = [];
|
|
64
|
-
|
|
65
|
-
// Auth prefix
|
|
66
|
-
if (authRole === 'no-auth') {
|
|
67
|
-
parts.push('Not authenticated.');
|
|
68
|
-
} else if (authRole) {
|
|
69
|
-
parts.push(`Logged in as ${authRole}.`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Given lines (clean + period-separated)
|
|
73
|
-
for (const line of givenLines) {
|
|
74
|
-
const cleaned = cleanStepLine(line);
|
|
75
|
-
if (!cleaned) continue;
|
|
76
|
-
parts.push(cleaned.endsWith('.') ? cleaned : cleaned + '.');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return parts.join(' ').trim();
|
|
101
|
+
export function formatPrecondition(authRole: string | null, _givenLines: string[] = []): string {
|
|
102
|
+
if (authRole === 'no-auth') return 'Not authenticated';
|
|
103
|
+
if (authRole) return `Logged in as ${authRole}`;
|
|
104
|
+
return '';
|
|
80
105
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sun* logo embedded as base64 PNG. Sourced once from template_report.xlsx
|
|
3
|
+
* (Sample sheet, anchored over A1:C3) and inlined here so the xlsx exporter
|
|
4
|
+
* has no filesystem dependency at runtime.
|
|
5
|
+
*
|
|
6
|
+
* Decode + use:
|
|
7
|
+
* wb.addImage({ base64: SUN_LOGO_PNG_BASE64, extension: 'png' });
|
|
8
|
+
*/
|
|
9
|
+
export const SUN_LOGO_PNG_BASE64 =
|
|
10
|
+
'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';
|