@sun-asterisk/sungen 2.6.8 → 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.
Files changed (84) hide show
  1. package/dist/cli/commands/dashboard.d.ts +2 -1
  2. package/dist/cli/commands/dashboard.d.ts.map +1 -1
  3. package/dist/cli/commands/dashboard.js +9 -9
  4. package/dist/cli/commands/dashboard.js.map +1 -1
  5. package/dist/cli/commands/delivery.d.ts.map +1 -1
  6. package/dist/cli/commands/delivery.js +33 -0
  7. package/dist/cli/commands/delivery.js.map +1 -1
  8. package/dist/cli/index.js +1 -1
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/dashboard/history-store.d.ts +13 -9
  11. package/dist/dashboard/history-store.d.ts.map +1 -1
  12. package/dist/dashboard/history-store.js +19 -28
  13. package/dist/dashboard/history-store.js.map +1 -1
  14. package/dist/dashboard/html-renderer.d.ts +1 -1
  15. package/dist/dashboard/html-renderer.d.ts.map +1 -1
  16. package/dist/dashboard/html-renderer.js +2 -2
  17. package/dist/dashboard/html-renderer.js.map +1 -1
  18. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  19. package/dist/dashboard/snapshot-builder.js +38 -2
  20. package/dist/dashboard/snapshot-builder.js.map +1 -1
  21. package/dist/dashboard/templates/index.html +142 -221
  22. package/dist/exporters/csv-exporter.d.ts +4 -0
  23. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  24. package/dist/exporters/csv-exporter.js +35 -26
  25. package/dist/exporters/csv-exporter.js.map +1 -1
  26. package/dist/exporters/feature-parser.d.ts.map +1 -1
  27. package/dist/exporters/feature-parser.js +16 -4
  28. package/dist/exporters/feature-parser.js.map +1 -1
  29. package/dist/exporters/json-exporter.d.ts.map +1 -1
  30. package/dist/exporters/json-exporter.js +28 -20
  31. package/dist/exporters/json-exporter.js.map +1 -1
  32. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  33. package/dist/exporters/playwright-report-parser.js +22 -5
  34. package/dist/exporters/playwright-report-parser.js.map +1 -1
  35. package/dist/exporters/scenario-merger.d.ts +23 -1
  36. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  37. package/dist/exporters/scenario-merger.js +39 -0
  38. package/dist/exporters/scenario-merger.js.map +1 -1
  39. package/dist/exporters/step-formatter.d.ts +31 -3
  40. package/dist/exporters/step-formatter.d.ts.map +1 -1
  41. package/dist/exporters/step-formatter.js +52 -19
  42. package/dist/exporters/step-formatter.js.map +1 -1
  43. package/dist/exporters/sun-logo.d.ts +10 -0
  44. package/dist/exporters/sun-logo.d.ts.map +1 -0
  45. package/dist/exporters/sun-logo.js +13 -0
  46. package/dist/exporters/sun-logo.js.map +1 -0
  47. package/dist/exporters/test-data-resolver.d.ts +13 -5
  48. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  49. package/dist/exporters/test-data-resolver.js +36 -14
  50. package/dist/exporters/test-data-resolver.js.map +1 -1
  51. package/dist/exporters/types.d.ts +16 -0
  52. package/dist/exporters/types.d.ts.map +1 -1
  53. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  54. package/dist/exporters/xlsx-exporter.js +169 -99
  55. package/dist/exporters/xlsx-exporter.js.map +1 -1
  56. package/dist/orchestrator/templates/playwright.config.d.ts.map +1 -1
  57. package/dist/orchestrator/templates/playwright.config.js +2 -0
  58. package/dist/orchestrator/templates/playwright.config.js.map +1 -1
  59. package/dist/orchestrator/templates/playwright.config.ts +2 -0
  60. package/dist/orchestrator/templates/specs-base.d.ts.map +1 -1
  61. package/dist/orchestrator/templates/specs-base.js +1 -5
  62. package/dist/orchestrator/templates/specs-base.js.map +1 -1
  63. package/dist/orchestrator/templates/specs-base.ts +1 -5
  64. package/package.json +1 -1
  65. package/src/cli/commands/dashboard.ts +9 -9
  66. package/src/cli/commands/delivery.ts +30 -0
  67. package/src/cli/index.ts +1 -1
  68. package/src/dashboard/history-store.ts +22 -28
  69. package/src/dashboard/html-renderer.ts +6 -2
  70. package/src/dashboard/snapshot-builder.ts +36 -2
  71. package/src/dashboard/templates/index.html +142 -221
  72. package/src/dashboard/types.ts +1 -1
  73. package/src/exporters/csv-exporter.ts +44 -27
  74. package/src/exporters/feature-parser.ts +27 -8
  75. package/src/exporters/json-exporter.ts +31 -21
  76. package/src/exporters/playwright-report-parser.ts +23 -5
  77. package/src/exporters/scenario-merger.ts +65 -1
  78. package/src/exporters/step-formatter.ts +48 -23
  79. package/src/exporters/sun-logo.ts +10 -0
  80. package/src/exporters/test-data-resolver.ts +37 -13
  81. package/src/exporters/types.ts +18 -1
  82. package/src/exporters/xlsx-exporter.ts +176 -101
  83. package/src/orchestrator/templates/playwright.config.ts +2 -0
  84. package/src/orchestrator/templates/specs-base.ts +1 -5
@@ -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>; // Critical/High/Normal/Low
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 { formatNumberedSteps, formatPrecondition, cleanStepLine } from './step-formatter';
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
- // Prefer .spec.ts resolved comments for Steps/Expected if available
54
- let steps: string;
55
- let expectedResults: string;
56
- let precondition: string;
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
- if (m.spec) {
59
- precondition = formatPrecondition(authRole, m.spec.precondition);
60
- steps = formatNumberedSteps(m.spec.steps);
61
- expectedResults = formatNumberedSteps(m.spec.expectations);
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 = `${input.env.baseURL} (${input.env.projectName})`;
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 = `${input.env.baseURL} (${input.env.projectName})`;
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
- '{Category 1}',
208
- '{Category 2}',
209
- '{Category 3}',
210
- '{Category 4}',
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): { given: string[]; when: string[]; then: string[] } {
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: when,
77
- rawThenSteps: then,
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, cleanStepLine } from './step-formatter';
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
- let stepsRaw: string[];
60
- let expectedRaw: string[];
61
- let givenForPrecondition: string[];
62
-
63
- if (m.spec) {
64
- givenForPrecondition = m.spec.precondition;
65
- stepsRaw = m.spec.steps;
66
- expectedRaw = m.spec.expectations;
67
- } else {
68
- givenForPrecondition = m.feature.rawGivenSteps;
69
- stepsRaw = m.feature.rawWhenSteps;
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) errorMessage = String(result.error).split('\n')[0].trim();
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 = `${input.env.baseURL} (${input.env.projectName})`;
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  and  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
- const firstLine = (result.error || '').split('\n')[0].trim();
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) parts.push(`Error: ${truncated}`);
173
- if (result.tracePath) parts.push(`Trace: ${result.tracePath}`);
174
- return parts.join('. ');
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
- * Natural language for Given → Pre-condition.
57
- * Combines auth info + Given steps into a single sentence.
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: string | null,
61
- givenLines: string[]
62
- ): string {
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
+ 'iVBORw0KGgoAAAANSUhEUgAAAlgAAAFWCAIAAAA65nRmAAAdgklEQVR4Xu3deZzU9P3H8Qri/fNovY9fVVhEQJFT1msED6SASLceAyJYBa2KooBWtIKiUq0WtVSqrVoFpB4VtIoiVgWsFxYRi4Igh6uCKIfIcu7y/eVb/OHyyU4mk2Rmk/m8no/3HwqTb5Jl8n1PZmeSHxkAABT7kfwDAAA0oQgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAgBUowgBAKpRhAAA1ShCAIBqFCEAQDWKEACgGkUIAFCNIgQAqEYRAkA469eZuwab0oPM4T/6PqcfacaOMps3y0cilihCAAhh00Zz7ok/VGD1DO0nH4xYoggBIIRH7pH9Vz1vvy4fj/ihCAEghLNay/KrnhsukY9H/FCEABBUVZVpvLMsv+opK5WLIH4oQgAIqnyhbD6RY/aUiyB+KEIACGrys7L53Fn6uVwKMUMRAkBQI2+VtefOlBflUogZihCeVq8yy5bY93/mfWQ+mmn/w8nyZaZyk3wkoNCV58nac+fBO+VSiBmKEP/vswVm8gQzcpg9tsvamhN/ao7YQR7S1dPix+aUhqZHezO4rz3UX3mOt4CQVC8+bS44zZx0qP0I6P23m4o18gGZdGgijwt3BvSUS2Xy1ZdmWH/TtZXp3NwM7GVmvy8fgPygCHVb+oV58iFzdQ/T9gB59AZLm/3MRZ3MwyPsMVxVJVcHxNBt18incbsG5sP35MPcNm4wJdvLZd3p1EwuWKOX/m4/WVN9wYb1zIQx8mHIA4pQpVUrzLgHTPd2pv528qCNMMfub79ENXWSvfQGEE93XCeft1tyxA7mgTuyvJj7aKZcqsY4Q3n/KsE5AXWOFPeCThrUoQsLgCJUZsFcc9Nlpsku8njLa1rtY24fYOZ/LDcGqF3PPyGfqyLpk82ScrnUVuNHy8dnyrzZctmtZk23v2JwL7I1zknn7BlyKUSKIlRj8Xxzxdn5PQXMmh7t7SfouBIxYqKsVD5F3TlmT/P83+SCW/z2WvngTKlxhMpK88fbfL25Oqi3XBaRoggV+HaluflK+/sG9wFWK+l4lJn2stxIoPDE7+Q8MqCn+e5bufiFHeXDMuXuG+Sy5QvNOSfIh2XKmS3l4ogURVjsXn3elB4oj6s4ZOJTclOBAmu1j3xaeuSkQ817b2yz+HEHy8dkSt8zt1lw/Ghz9O7yMR5Jp7ZZHFGjCItXxRr7jor7oIpJWvzErK2Q2wwU0mVl8mnpnQZ17Lndlg9/fbtS/q1HnBLdwlmq37nyb7Nm1PBqG43oUYRF6vNF9h1I9xEVq/AGKWrX3A9Nox3l0zJrzmptP3T2zhT5595Zs9reksn/SeTWlB5Uw7uyiBRFWIzmzIrp26EiTz8itxwosEfvk09LP2m6q+ndQf6hd3718yAfVXPOQd+dKrcZUaMIi47Tgi33lodTPEMRotZt3pzDZ14Kn7tvlBuMPKAIi8vni5JxLrglFCHiYNmSmL527NqKi1EUBkVYRNZW2Is5uQ+n2IYiRExMniCfnLWexjubT+fI7UR+UIRFZHBfeSzFPBQh4uP6i+Xzs3Yz5n65hcgbirBYTJ0kD6T4hyJEfFR8Z06uL5+itZULO3IBpkKiCIvCxg0xOob9hyJErMx405TUlc/Swqfl3ubrpXLbkE8UYVF49A/yWEpEKELEze9/I5+lhc/kZ+VWIc8owuSr3GSOP0QeS4kIRYi4cY6mbm3kE7WQuf5iuUnIP4ow+V76uzyWkhKKEDG08BP7fXn307UAObm+/VUlCo4iTL6+XeXhlJRQhIinx/8kn6sFSEld+0tK1AaKMOE2rM/jXXYb1jOnHmH6dDEDe5mhV5jhg+wVh2+5yt7U6eoepvcZpksL03wvuZT/UISIp1UrzM+Olk/XfKd/dy5DX1sowoR7Y7I8nEKm/namezvz4J3m4w/s70v8WL3KzHjLjB1lBvexxekeM1NeHi+HAgpv00b7bB8/2t5o98KOQa6LHVWco69dA3NpN3PPEPsrj4WfmKoqubXIA4ow4SL8kFuDOrbJyhfKVeRq2RLz1MP2PPLIneQqqsf525XL5bJAAXz5mb1P56jh9iTsjKa+7hFfW2myi73ZxXW/NA+PMG+9alZ8LfcFUaAIE8558eg+eAKk9EB7Vhct50zROU08s6Vc15ZwizUU0tRJ9nVeWVvTbA/5VExW2uxn0iebYf3NB+/KfURQFGHCnX6kPE4CxDm0Fs2TI0do+jR7h27njHPL6pyZ6KHfy8cAebJpo+l3jnzOF0dG3CR3FoFQhAnXsJ48NgJk8gQ5bD5885V9Vf72a3xAHAV1383yCV9M4RftUaAIk2zjBnlUBMhpjeSwQNGoqorpLZaiSllbucvIHUWYZCuXy6MiQG64RA4LFI2lX8gnfJGlZHu5y8gdRZhk5QvlUREg9wyRwwK5qqw0s2eYv/3Z3HaNueQse5rSroE58ac2qcPtbTLLSk2/c82dv7aP+c+//X4zJ7xIjpGYB6HxQ0yy5cvkIREgN14qh02u+R+b116wvzXJmikvmSXlcvHIzf3QfkzfvXZ3pk6yXzvJny8W2112r9ed1yeaBXPl4pl896155jHbfEfvLp9X3mm6q+nR3jx6nz1jyytnx91rL7IgNH6ISbZmtTwkAqRrKzlsEs2ZZS9z494771zUKV/1M+Mtc3pjuTrv1N/OnjOtWiGHCslpml6ny3Vlzc+Pta8qPDh/e/3FEVzVyNlrZ/OmvSzHj8rGDabRjnKlxZS2B8hdRu4owoSL5FOjTosk2ueLgl/p7bRGpmKNHDAk5+cZuCG6tYnybcPVq+xblO61+EmrfWo+aXb+8JrzbYG5FwmTzs3tF8bzwdla9+qKJsMHyf1F7ijChAs8zVXPeSfZ3/Ek1+A+co9yyoO/kwOG5JxoutfiP08+JAcMbOQwOXhOGdxXDjh2lDnqf+TDIkz/7vZrNtFyBozkMIlhnJdNfBkpChRhwgV416vGOFPe5s1y8KRoXyJ3J6f0aC8HDMN5SRHykl1XnC3HDOwXx8nBc8pxB/8w1NqKAn0tvc1+0Z8afrvSnjmdXF+uK6FxTsdPPcLcO9SsWyv3FIFQhAn322vlQRI4fc+076Ql0Yk/lfuSUzo1kwOG4cy57lXklHRKjhlY6jA5eE5ptsf34zhPjLJS+bf5i/NK4om/bLMjUXHOnz58z9725NarTc9TTet95arjmdKD7M1enIN9/Ggz+32zfp3cL4RDESbc5GflMRMmxx9iP+WYOBRhJiF/MluKcMN6c84J8q8KkDH3y93Jh2++Mv96xV7zb1Bve13cxjvLzSh8mu5qv38yuI/9VO3br0X/+Sm4UIQJt3L5D9fwjCrplHn7dbmiOAs53VOEmbKlCEP+CjZMnBOgAqusNJ/OMROfMh2ayI3Jd5xz7pfHm88+TfAvKRKLIky+nqfKIyqS9O4Q/f0o8iTkdE8RZopThK+9IP+wkGlYz8x8R+5UAbw7NfrXl1njnIx6f2UFeUMRJt+TD8kjKsJc2NG88lzcP1MacrqnCDOl6W7R3N4kTJxdKPAHI1evCvtzC5wuLey9MlBwFGHyOdNE4G/R+UzbA8xdg+2bNvEUctqiCGOem6+U+5VXV6XlBhQyd/5abg/yjyIsCk5LuY+ofKRHe/Ps2Nh9aC3kdE8RxjwN6phZ0+Wu5cmEMXLtBY6zs9Onya1CnlGERWHlN+aYPeURlb84J6BD+8XoejQhp3uKMP7p3UHuWj6UL8z5oqn5yEmH2ou4ooAowmLx6H3ycCpAurWxNxNYs1puTIGFnO4pwkQk3x/dqqwMe/2BCDOwl9w85BNFWCwqN9kLJbuPqAKk6W7mul/au/DUlpDTPUWYiPQ7R+5dtEJeji7yTHxKbiHyhiIsIovn5/c6kFlTVmqee9xe77/AQk73FGEicsQOefxquXO6WVJXrrF20+LHZunncjuRHxRhcZn0TPS3Bcg1pQfZy1gX8iPvIad7ijApeWyk3MFIOM/VeF6G9ILT+HJ9YVCERecvd8vDqVbSbA9z9422FQog5HRPESYlTjHkw6DeckXxyV/vlVuLPKAIi5HTQO4jqlbi1OHIYXk/Oww53VOEgdN8L3tho9/8yj7l7hlihlxu/7fprvJhUaXRjtHfb+H5J+RafOa4g+WfeCfXx2+Js8sLP5HbjKhRhEVq1HB5RNViWu9rP1xaVSU3Miohp3uKMNc4VXfLVeb9t2v+N924wfzzH/YLD+4Fw2fqJLm6MMoX2tdq7rV4p9U+dgef+Iv8c480rGdfDg69Qv65n/zyZ3KzETWKsHhNGGNfTrqPq9pKlxZ26syHkNM9Reg/JXXNsP5+3/GePs2c3liOEDIjh8m1hHHleXL8rLmo0/e3Dp75jvwrj2x9jr0+0Ry7v/zbrHljcrWNRvQowqLmHKvB3pDJUxrUsfeBW1shtzOkkNM9RegzpQfmfNGTdWvNgJ5ynDC5rEyuIrBNG+0nUd2ryJTGO9s7Q2399ErFmhw+mHbN+T+sd/kye+9P92M8cuOlPyyOPKAIi92qFabfufK4qt20L7E3F41QyOmeIvSTk+ubLxbL8f1wmsN59eMeMFiczYiKszvu8TOlc/Mabg3h/7OmD9whlx33QA6/TL20m1wckaIIdfjHONNmP3l01WIa7Wge/5PcyMBCTvcUYda0+In9lmpgThf27y7HDJaSujX/YjIA54zwyJ3k+O40qGPuuK7mm0JccpZ8cKa8PlEu61gw13RtJR9ZY4YPlMsiUhShGqtX2U/3xepbw0P7RXODp5DTPUWYNa88J0fOVcV39p0A98gB8tWXcvDAsn5x4vhDvG5SPeIm+fhMWVIul93C6de7Bme592H97SJ+BwUuFKEy8z82fbvKI60Wc2m3CK5EE3K6pwi9E9X7clMnyZGD5YN35ciBrVphzmgqx9+aK8/L8rGgiU/JRWrMMXvKBYXp07z+pe67WT4eUaMIVZrxlunTRR5vtRVnugl5XugxifgJReiduR/KYQM790Q5eIC8+rwcNozly2rowqa7mmcek490+3SOXLDGnHeSXNDtu29r/gjrHdfJRyIPKELFPpppP8zWsJ489gqfIZfLbctJyOmeIvRI93ZyzDACf3u9el4eL4cNyfknG9zn+4+uNKhjP9Lp8zvszgs4P79l9P/0Hj/atGvw/VJtDzDjHpQPQH5QhOp9vdTcO7T2v2Xxtz/LDfMv5HRPEXok2st7rl9nv4TgXktO8XOuFoDTal8szvmm011ayM1zZ+wouZQ355BcUh7ZZ4LgA0WI/3JmgSkvmivOzu2bVREmzKWkQk73FKFH3N8ZCMk5xXSvJac8/YgcsxYN7CU3z5333pBLIWYoQmxr5XIz+o/mrNbyYC5Azj0x4LX2Q073FGGmOK+Kgv2LeLj5SrmWXBOrIvRzjXtuNx97FCEymDfbDB9kf1HhPrDzl2C//gk53VOEmXJaIzlgeOGvghurIpz2stw8EeefALFHEcJTZaV57QX78QHvrzpFFaeTAvxqJOR0TxFmStdWcsDwHh4h15JrYlWEX34mN0+k9xlyEcQPRQh/nAN+xE2FOEEMcH3hkNM9RZgp6ZQcMLwiK0LH0bvLLaye2wfIxyN+KELkonKTefFpU1Yqj/YIc+V5cqVZhZzuKcJMSafkgOEVXxF6X8v3zX/KxyN+KEIEMn2a/Uq+/6vv+8+RO+V889WQ0z1FmCnplBwwvOIrwgVzM54UOscIkoAiRAjzZtvrb7mP/5CZ8qJckbeQ0z1FmCnplBwwvOIrQsd//m06NNlmI0vqmusvjv6OY8gPihChvfeG6XiUnK3C5LZr5Cq8hZzuKcJMSafkgOEVZRGa/95hY84se/m3l8ebf71ir9yG5KAIEYX167JfyN9//FybsbqQ0z1FmCnplBwwvGItQiQZRYiIOK+Ib7lKzlnBcvTucnBvIad7ijBT0ik5YHgUIeKHIkR0qqpMz1PltBUsy5bIwT2EnO4pwkxJp+SA4VGEiB+KEJFaNC+ae/9++J4c2UPI6Z4izJR0Sg4YHkWI+KEIEbUrzpYzV4DkdM+5kNN9tEX49VI5fq5Jp+SYgYX8yaRTcsDwKELED0WIqI0fLWeuAHnmUTmsh5DT/SkN5YBhLJ4vx8816ZQcM7CQP5l0Sg4YHkWI+KEIEbWPP5AzV4DkNNmFnO5LD5QDhvHOFDl+rkmn5JiBhfzJpFNywPAoQsQPRYiolS+UM1eAONOlfyGn+5K6Qa70ncm4B+T4uSadkmMGFvInk07JAcOjCBE/FCGi9sl/5MwVIE89LIf1cNKhcvFc8+kcOWZg114oB8816ZQcMzCKEPCBIkw+5wzsvTfMF4vln9eWZ8fKmStAXnhSDuuhUzO5eK4ZP1qOGYxzZnns/nLwXJNOyWEDowgBHyjCJJv7oSlr+8ME4fz39GnyMYXXp4ucuQLktRfksB7ObCkXzzXXnC/HDCbrbVr9JJ2SwwZGEQI+UISJNf/jmq95f1mZ+exT+eCCmfGW3J5g+WimHNnDhR3l4rmm6W6m4js5bAC9O8iRA8R5QRMVihDwgSJMLI+7oDWsZ698v/ATuUi+LV9mUofLjQmWnO7ENLivXDxAHvq9HDZXkZwOOunQRI4cGEUI+EARJtYxe8oJQqT+dva77e9OtVcBLYAl5eaMpnIbguWE/5WDe/vDLXKEAGm+l/nmKzmyf6tW2M12DxsgzfaQgwdGEQI+UISJ5Z4gMqV9iRk13BZV/jz/hGm9r1xv4PTtKsf3NmGMHCFYep9hKivl4H5s3GDOP0WOFiZffSlXEQxFCPhAESaWe4LIms7NzT1DzKzpAad7t6oqM3mCKSuVKwqZR/8gV+Qtki9sbEn/7qZykxzf27crI27Bw6Ob6ylCwAeKMLHcE4T/NN3N3iZixE22xhbMzXnqX7XCvPKcGXK5aXuAHDmSzPtIrtGb0+tH7iQHCZzzTrLfSPHprVcj+7Vo9XQ+JpoXKxQh4ANFmFjuCSJwGtYzpzUyF5xmz4eGDzQP3GFnq7Gj7IyzJc4p2r1DzQ2X2E9Fhpxbs+bk+nJP/TjnBDlOmDTe2Qzrbz+Xm4nTUlMn2bdS3ctGlcF9c36B4hbyHyudkgOGRxEifijCxHJPEMWRe4bIPfVj5DA5TiQ5paG9Usz9t9sr3Tjz77gHzN03mku7mZZ7y0fmI6nDzO0DzON/Mi/93d7UIgCKEPCBIkws9wRRBCmpa5Z+LvfUj1nT5VBFFues/c93yb3OiiIEfKAIE8s9QRRBBvaSu+lTVZU5/hA5WvHl9Ylyx71RhIAPFGFilWwvJ4ikx9mjRfPkbvo34iY5YPHlirPlXnujCAEfKMLEaraHnCCSnmH95T7m5PNF9hoC7mGLKemU3GtvFCHgA0WYWEVWhMcfYtaslvuYK4/LzhVH0im5y94oQsAHijCxiqkIG9Qxb78udzCAebOL/KQwnZK77I0iBHygCBOrmIpw1HC5d4ENuEAOXkxJp+T+eqMIAR8owsQ67mA5QSQ0A3tFeVnwld+YNvvJVRRN0im5v94oQsAHijCxLu4sJ4gk5rKyaK4lVt2LT8u11EoG9JR/Ej7plNxZbxQh4ANFmFhvTJYTROIy4ILoW3CLIZfLdRU4l3YzFWvMsfvLPw+ZdEruqTeKEPCBIkyyB39nP2bininin/rb2YuiRfiOqFC5Kb8XAvVOy73NsiV2M8aOkn8VMunUtvuZDUUI+EARJtzs9+3d+9yTRZzTZj8z5SW5I5FbW2G6t5OrLkBKtjf/euX7baiqingb0qlqe+gDRQj4QBEWhdkzzOW/SMY3B65KmxVfy+3Pk/Xr7O8g3duQvzgn6BPGbLMNzqlhyDaqnnRqm8GzCrnqdEoOGB5FiPihCIvIwk/MrVeb5nvJiSMm6dzc3r2vwDZvNiNvtdfydm9P5Gm0o3nhSbkBjkXzTOlB8sHBkk7Jwb11ayNHyCkXdZIDhjd+tFxLrpn4lBwTCIciLDprK8yTD9n788XnBPEXx9kbCeXvN4JZzXzHdGkhtyratGtg74CRSflC0/EouUiA9OkiR/Z212A5Qk4JcL+LrL5YHOoX285rmq++lGMC4VCExcuZcZyJ7OfHyqmkYGmyi30jdMabcsNqRVWVvZtg6YFyI8PHORH87bX29Ye3iu/MoN5y2Vwz+o9yWG8rvwm+y6nD7Dbnw9B+cl3+M3ygHA0IjSJUwHkF/cyj5przTet95bSSjzTd1X5+5x/j8jWNhrFxg31rrnNzuc3B0nQ3O6c7Lzj8e/s1+3HWYCfrzmavXycHzOrTOeZnR8uhsubMluazBXKoqDj/Cs7PLdcfgnMuOKx/vr5vA90oQk02bzbzP7ZN4ExDZW1N453lXBM4Lfc2PU+134iYPs1Oc/G3YK69rpvzQzhiB7kvWXP07vYzOM6PsWKNHNan8oX2ZP38U3L4J+h7plm+TI7jk3M2/M4Uezb58Ijscc6bZ7xViPexnRcQzs/QvQE15rnHzZJyOQIQEYpQMWey+/Iz+wGWcQ/aN/cG9rInK11b2RtBNNtDXsu0yS72T9oeYE4/0s7gV/cwwweZv95r3vxn8Ak6Djast2/ePjbS3D7A1ttZrc3pje2HLVv8xH7NI3WY6dTMnH28/VCu84CnHzEfzbS9EpXKTWbOLPvpD6cX7xlibrnK/Poi+w9xwyX2v393ve2AZ8eGuk0jgGwoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoBpFCABQjSIEAKhGEQIAVKMIAQCqUYQAANUoQgCAahQhAEA1ihAAoNr/AYmONsW7vT/nAAAAAElFTkSuQmCC';