@sun-asterisk/sungen 3.2.33 → 3.2.34
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/audit.d.ts +4 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +16 -3
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/changed.d.ts.map +1 -1
- package/dist/cli/commands/changed.js +2 -3
- package/dist/cli/commands/changed.js.map +1 -1
- package/dist/cli/commands/dashboard.d.ts.map +1 -1
- package/dist/cli/commands/dashboard.js +2 -28
- 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 +40 -35
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/results-merge.d.ts +9 -0
- package/dist/cli/commands/results-merge.d.ts.map +1 -1
- package/dist/cli/commands/results-merge.js +7 -0
- package/dist/cli/commands/results-merge.js.map +1 -1
- package/dist/cli/commands/select.d.ts +12 -0
- package/dist/cli/commands/select.d.ts.map +1 -0
- package/dist/cli/commands/select.js +122 -0
- package/dist/cli/commands/select.js.map +1 -0
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +3 -0
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/index.js +7 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/templates/index.html +1 -1
- package/dist/dashboard/types.d.ts +1 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +11 -10
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/json-exporter.d.ts.map +1 -1
- package/dist/exporters/json-exporter.js +7 -0
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/manual-hybrid-oracle.d.ts +54 -0
- package/dist/exporters/manual-hybrid-oracle.d.ts.map +1 -0
- package/dist/exporters/manual-hybrid-oracle.js +114 -0
- package/dist/exporters/manual-hybrid-oracle.js.map +1 -0
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +6 -2
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts +3 -0
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +1 -0
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/gates.js +6 -0
- package/dist/exporters/matrix/gates.js.map +1 -1
- package/dist/exporters/matrix/render-testcase-sheet.d.ts.map +1 -1
- package/dist/exporters/matrix/render-testcase-sheet.js +10 -4
- package/dist/exporters/matrix/render-testcase-sheet.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +8 -1
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/matrix/wording.d.ts +1 -1
- package/dist/exporters/matrix/wording.d.ts.map +1 -1
- package/dist/exporters/matrix/wording.js +10 -1
- package/dist/exporters/matrix/wording.js.map +1 -1
- package/dist/exporters/run-environment.d.ts +32 -0
- package/dist/exporters/run-environment.d.ts.map +1 -0
- package/dist/exporters/run-environment.js +125 -0
- package/dist/exporters/run-environment.js.map +1 -0
- package/dist/exporters/scenario-merger.d.ts +18 -0
- package/dist/exporters/scenario-merger.d.ts.map +1 -1
- package/dist/exporters/scenario-merger.js +47 -3
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/test-data-resolver.d.ts +0 -4
- package/dist/exporters/test-data-resolver.d.ts.map +1 -1
- package/dist/exporters/test-data-resolver.js +0 -7
- package/dist/exporters/test-data-resolver.js.map +1 -1
- package/dist/generators/gherkin-parser/index.d.ts +11 -4
- package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
- package/dist/generators/gherkin-parser/index.js +49 -19
- package/dist/generators/gherkin-parser/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts +4 -0
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/click-select-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/pick-option.hbs +13 -0
- package/dist/generators/test-generator/adapters/playwright/templates/test-file.hbs +16 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +23 -2
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +3 -1
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +3 -1
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/harness/audit.d.ts +3 -0
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +17 -8
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/data-driven-lint.d.ts.map +1 -1
- package/dist/harness/data-driven-lint.js +69 -27
- package/dist/harness/data-driven-lint.js.map +1 -1
- package/dist/harness/next-step.d.ts.map +1 -1
- package/dist/harness/next-step.js +1 -0
- package/dist/harness/next-step.js.map +1 -1
- package/dist/harness/parse.d.ts +24 -0
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +117 -13
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/quality-gates.d.ts.map +1 -1
- package/dist/harness/quality-gates.js +6 -4
- package/dist/harness/quality-gates.js.map +1 -1
- package/dist/harness/read-text.d.ts +15 -0
- package/dist/harness/read-text.d.ts.map +1 -1
- package/dist/harness/read-text.js +25 -0
- package/dist/harness/read-text.js.map +1 -1
- package/dist/harness/select-by-ids.d.ts +31 -0
- package/dist/harness/select-by-ids.d.ts.map +1 -0
- package/dist/harness/select-by-ids.js +106 -0
- package/dist/harness/select-by-ids.js.map +1 -0
- package/dist/harness/sensors.d.ts +8 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +61 -9
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/spec-branches.js +1 -1
- package/dist/harness/spec-branches.js.map +1 -1
- package/dist/harness/ui-inventory.d.ts +40 -0
- package/dist/harness/ui-inventory.d.ts.map +1 -0
- package/dist/harness/ui-inventory.js +191 -0
- package/dist/harness/ui-inventory.js.map +1 -0
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +64 -9
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/orchestrator/screen-manager.d.ts.map +1 -1
- package/dist/orchestrator/screen-manager.js +3 -11
- package/dist/orchestrator/screen-manager.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/agents/discovery.md +11 -3
- package/dist/orchestrator/templates/ai-src/agents/reviewer.md +8 -0
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/dist/orchestrator/templates/ai-src/commands/review.md +2 -2
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +70 -13
- package/dist/orchestrator/templates/ai-src/skills/sungen-capture/mode-local.md +2 -2
- package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +20 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +17 -8
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +58 -14
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +41 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +7 -0
- package/dist/orchestrator/templates/playwright.config.ts +4 -2
- package/dist/orchestrator/templates/specs-url-assert.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-url-assert.js +24 -2
- package/dist/orchestrator/templates/specs-url-assert.js.map +1 -1
- package/dist/orchestrator/templates/specs-url-assert.ts +23 -2
- package/dist/orchestrator/trace-mode-install.d.ts +14 -0
- package/dist/orchestrator/trace-mode-install.d.ts.map +1 -0
- package/dist/orchestrator/trace-mode-install.js +132 -0
- package/dist/orchestrator/trace-mode-install.js.map +1 -0
- package/dist/orchestrator/viewpoint-pattern-catalog.d.ts +20 -0
- package/dist/orchestrator/viewpoint-pattern-catalog.d.ts.map +1 -0
- package/dist/orchestrator/viewpoint-pattern-catalog.js +45 -0
- package/dist/orchestrator/viewpoint-pattern-catalog.js.map +1 -0
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +14 -3
- package/src/cli/commands/changed.ts +2 -4
- package/src/cli/commands/dashboard.ts +1 -26
- package/src/cli/commands/delivery.ts +37 -32
- package/src/cli/commands/results-merge.ts +7 -1
- package/src/cli/commands/select.ts +88 -0
- package/src/cli/commands/update.ts +3 -0
- package/src/cli/index.ts +7 -2
- package/src/dashboard/templates/index.html +1 -1
- package/src/dashboard/types.ts +1 -0
- package/src/exporters/csv-exporter.ts +7 -10
- package/src/exporters/json-exporter.ts +6 -1
- package/src/exporters/manual-hybrid-oracle.ts +112 -0
- package/src/exporters/matrix/build.ts +6 -2
- package/src/exporters/matrix/export.ts +4 -0
- package/src/exporters/matrix/gates.ts +6 -0
- package/src/exporters/matrix/render-testcase-sheet.ts +10 -4
- package/src/exporters/matrix/types.ts +8 -1
- package/src/exporters/matrix/wording.ts +10 -1
- package/src/exporters/run-environment.ts +89 -0
- package/src/exporters/scenario-merger.ts +49 -4
- package/src/exporters/test-data-resolver.ts +0 -6
- package/src/generators/gherkin-parser/index.ts +50 -18
- package/src/generators/test-generator/adapters/adapter-interface.ts +4 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/click-select-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/pick-option.hbs +13 -0
- package/src/generators/test-generator/adapters/playwright/templates/test-file.hbs +16 -0
- package/src/generators/test-generator/code-generator.ts +23 -2
- package/src/generators/test-generator/diagnostics.ts +3 -1
- package/src/harness/audit.ts +20 -9
- package/src/harness/data-driven-lint.ts +82 -24
- package/src/harness/next-step.ts +1 -0
- package/src/harness/parse.ts +123 -16
- package/src/harness/quality-gates.ts +6 -4
- package/src/harness/read-text.ts +25 -0
- package/src/harness/select-by-ids.ts +83 -0
- package/src/harness/sensors.ts +59 -9
- package/src/harness/spec-branches.ts +1 -1
- package/src/harness/ui-inventory.ts +164 -0
- package/src/harness/viewpoint-ledger.ts +57 -11
- package/src/orchestrator/screen-manager.ts +3 -11
- package/src/orchestrator/templates/ai-src/agents/discovery.md +11 -3
- package/src/orchestrator/templates/ai-src/agents/reviewer.md +8 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/src/orchestrator/templates/ai-src/commands/review.md +2 -2
- package/src/orchestrator/templates/ai-src/commands/run-test.md +70 -13
- package/src/orchestrator/templates/ai-src/skills/sungen-capture/mode-local.md +2 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +20 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +17 -8
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +58 -14
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +41 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +7 -0
- package/src/orchestrator/templates/playwright.config.ts +4 -2
- package/src/orchestrator/templates/specs-url-assert.ts +23 -2
- package/src/orchestrator/trace-mode-install.ts +94 -0
- package/src/orchestrator/viewpoint-pattern-catalog.ts +58 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import { getPackageVersion } from './package-info';
|
|
9
|
-
import { MergedScenario } from './scenario-merger';
|
|
9
|
+
import { MergedScenario, extractManualReason } from './scenario-merger';
|
|
10
10
|
import {
|
|
11
11
|
extractAuthRole,
|
|
12
12
|
extractPriority,
|
|
@@ -27,6 +27,7 @@ import { collectSecretValues, formatApiCallSummaryLines, formatApiExpected, form
|
|
|
27
27
|
import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
|
|
28
28
|
import { formatVariantRow, resolveResultVariants } from './result-variants';
|
|
29
29
|
import { collectQaEnvSecretDenylist, warnIfRedactionIncomplete } from './qa-env-secret-denylist';
|
|
30
|
+
import { formatEnv } from './run-environment';
|
|
30
31
|
|
|
31
32
|
export interface BuildCsvInput {
|
|
32
33
|
screen: string;
|
|
@@ -207,6 +208,11 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
|
|
|
207
208
|
testResult = testcaseType === 'Manual' ? 'Pending' : 'N/A';
|
|
208
209
|
if (testcaseType !== 'Manual') {
|
|
209
210
|
note = 'Scenario not compiled — re-run `sungen generate --screen ' + input.screen + '`';
|
|
211
|
+
} else {
|
|
212
|
+
// Why the case is manual — the `# MANUAL: …` comment — so the tester reading the
|
|
213
|
+
// deliverable knows what blocks automation (#810). Empty when no reason is written.
|
|
214
|
+
const reason = extractManualReason(m.feature.comments ?? []);
|
|
215
|
+
if (reason) note = redactSecrets(`Manual: ${reason}`, qaEnvSecrets.values);
|
|
210
216
|
}
|
|
211
217
|
} else {
|
|
212
218
|
// Compiled but no execution record → Pending
|
|
@@ -290,15 +296,6 @@ export function buildSummary(screen: string, rows: TestCaseRow[], outputFile: st
|
|
|
290
296
|
* the baseURL so the deliverable shows the exact URL each test targets
|
|
291
297
|
* (e.g. `https://staging.example.com/kudos (chromium)`).
|
|
292
298
|
*/
|
|
293
|
-
function formatEnv(baseURL: string, featurePath: string | undefined, projectName: string): string {
|
|
294
|
-
const base = (baseURL || '').replace(/\/+$/, '');
|
|
295
|
-
const p = (featurePath || '').replace(/^\/+/, '');
|
|
296
|
-
// Join with '/' only when there is a baseURL (web). For mobile baseURL is empty
|
|
297
|
-
// and `p` is an app id (e.g. com.kngroup.media.converter) — emit it bare, no '/'.
|
|
298
|
-
const url = base && p ? `${base}/${p}` : (p || base);
|
|
299
|
-
return `${url} (${projectName})`;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
299
|
function csvCell(v: string | number | undefined | null): string {
|
|
303
300
|
if (v === null || v === undefined) return '';
|
|
304
301
|
const s = String(v);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* richer ScenarioSnapshot / ScreenSnapshot shape used by the dashboard UI.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { MergedScenario } from './scenario-merger';
|
|
8
|
+
import { MergedScenario, extractManualReason } from './scenario-merger';
|
|
9
9
|
import {
|
|
10
10
|
extractAuthRole,
|
|
11
11
|
extractPriority,
|
|
@@ -192,6 +192,7 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
192
192
|
let testEnvironment: string | undefined;
|
|
193
193
|
let testExecutor: string | undefined;
|
|
194
194
|
let type: ScenarioSnapshot['type'];
|
|
195
|
+
let manualReason: string | undefined;
|
|
195
196
|
|
|
196
197
|
if (result) {
|
|
197
198
|
// A result exists → the scenario compiled AND ran; authoritative over the `!m.spec`
|
|
@@ -224,6 +225,9 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
224
225
|
// No result AND no compiled test → Manual if @manual, else genuinely not compiled.
|
|
225
226
|
type = baseType === 'Manual' ? 'Manual' : 'Not compiled';
|
|
226
227
|
status = baseType === 'Manual' ? 'Pending' : 'N/A';
|
|
228
|
+
// Same Note source as csv-exporter: the `# MANUAL: …` reason (#810).
|
|
229
|
+
const reason = baseType === 'Manual' ? extractManualReason(m.feature.comments ?? []) : '';
|
|
230
|
+
if (reason) manualReason = redactSecrets(reason, qaEnvSecrets.values);
|
|
227
231
|
} else {
|
|
228
232
|
// Compiled but no execution record → Pending.
|
|
229
233
|
type = baseType;
|
|
@@ -249,6 +253,7 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
|
|
|
249
253
|
executedDate,
|
|
250
254
|
errorMessage,
|
|
251
255
|
tracePath,
|
|
256
|
+
manualReason,
|
|
252
257
|
testEnvironment,
|
|
253
258
|
testExecutor,
|
|
254
259
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The @manual "presence placeholder" hybrid (#810) — one predicate for every stage.
|
|
3
|
+
*
|
|
4
|
+
* Shape: a @manual scenario keeps complete Gherkin, but its only assertions are bare presence
|
|
5
|
+
* checks (`Then User see [Job List] table`), while the REAL oracle ("the newer row appears above
|
|
6
|
+
* the older one") sits in an unlabelled `# Tester verifies:` comment. Both gates only asked "is
|
|
7
|
+
* there a Then?", and the exporter only captures numbered comment lines — so the deliverable said
|
|
8
|
+
* "See [Job List] table" and a tester ticking it Passed recorded a check nobody made.
|
|
9
|
+
*
|
|
10
|
+
* The compile gate (SG-W008), the audit gate (MANUAL-STEPS-INSUFFICIENT) and both exporters read
|
|
11
|
+
* the comment block through THIS module, so the three can never disagree about what the oracle is.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface KeywordStep {
|
|
15
|
+
/** Gherkin keyword as written: Given / When / Then / And / But (surrounding space allowed). */
|
|
16
|
+
keyword: string;
|
|
17
|
+
/** Step text without the keyword. */
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const NUMBERED = /^\d+[.)]\s*/;
|
|
22
|
+
const DIVIDER = /^[-=*_]{2,}/;
|
|
23
|
+
const HEADER = /^tester verifies\b\s*:?\s*(.*)$/i;
|
|
24
|
+
const LABELLED_ORACLE = /^\d+[.)]\s*[A-Za-z /]*?(observ|oracle|expect|result|then|assert)[A-Za-z /]*:/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The prose a `Tester verifies:` header carries when it is NOT written as numbered lines — the
|
|
28
|
+
* text on the header line itself plus the lines after it, up to the first numbered item or divider.
|
|
29
|
+
* Returns null when the block is empty (a bare header followed by numbered items is the structured
|
|
30
|
+
* shape, not this one) or when there is no header at all. Only the FIRST header's block is read —
|
|
31
|
+
* a second `Tester verifies:` ends it.
|
|
32
|
+
*/
|
|
33
|
+
export function extractUnlabeledOracle(comments: string[]): string | null {
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
let inBlock = false;
|
|
36
|
+
for (const raw of comments) {
|
|
37
|
+
const line = raw.trim();
|
|
38
|
+
if (!inBlock) {
|
|
39
|
+
const m = line.match(HEADER);
|
|
40
|
+
if (!m) continue;
|
|
41
|
+
inBlock = true;
|
|
42
|
+
if (m[1].trim()) parts.push(m[1].trim());
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!line) continue;
|
|
46
|
+
if (NUMBERED.test(line) || DIVIDER.test(line) || HEADER.test(line)) break;
|
|
47
|
+
parts.push(line);
|
|
48
|
+
}
|
|
49
|
+
const text = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
50
|
+
return text ? text : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when the comments state an oracle — a labelled numbered line, or unlabelled header prose. */
|
|
54
|
+
export function hasCommentOracle(comments: string[]): boolean {
|
|
55
|
+
return comments.some((c) => LABELLED_ORACLE.test(c.trim())) || extractUnlabeledOracle(comments) !== null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A bare presence assertion: the element exists, nothing about its content, order, count or state.
|
|
60
|
+
* `User see [Job List] table` / `User is on [Home] page` / `User see [Save] button is visible`.
|
|
61
|
+
* Anything after the element type (`match data:`, `with {{v}}`, `row 2`, `is sorted …`) is a real check.
|
|
62
|
+
*/
|
|
63
|
+
export function isPresenceOnlyAssertion(text: string): boolean {
|
|
64
|
+
return /^User (?:see|is on) \[[^\]]+\](?: [a-z]+)?(?: (?:is|are) (?:visible|displayed|shown))?\s*$/i.test(text.trim());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True when no assertion checks anything beyond presence — including when there is no assertion
|
|
69
|
+
* at all (a documentation-style manual). Only then may unlabelled comment prose stand in as the
|
|
70
|
+
* oracle: a Then that already asserts content/order/count IS the oracle, and replacing it with the
|
|
71
|
+
* prose would drop the real check from the deliverable.
|
|
72
|
+
*/
|
|
73
|
+
export function onlyPresenceAssertions(assertionTexts: string[]): boolean {
|
|
74
|
+
return assertionTexts.every(isPresenceOnlyAssertion);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The scenario's assertion steps — a Then, plus every And/But whose preceding explicit keyword is Then. */
|
|
78
|
+
export function assertionSteps(steps: KeywordStep[]): KeywordStep[] {
|
|
79
|
+
let bucket = '';
|
|
80
|
+
const out: KeywordStep[] = [];
|
|
81
|
+
for (const s of steps) {
|
|
82
|
+
const kw = s.keyword.trim();
|
|
83
|
+
if (kw === 'Given' || kw === 'When' || kw === 'Then') bucket = kw;
|
|
84
|
+
if (bucket === 'Then') out.push(s);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The banned hybrid: ≥1 assertion, every assertion a bare presence placeholder, and an oracle in
|
|
91
|
+
* the comments. A @manual whose Gherkin already asserts something real is fine — its comments are
|
|
92
|
+
* an elaboration, not the only statement of what the case checks.
|
|
93
|
+
*/
|
|
94
|
+
export function isPresencePlaceholderHybrid(steps: KeywordStep[], comments: string[]): boolean {
|
|
95
|
+
const asserts = assertionSteps(steps);
|
|
96
|
+
return asserts.length > 0 && onlyPresenceAssertions(asserts.map((s) => s.text)) && hasCommentOracle(comments);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Split raw scenario-block lines into keyword steps (for the text-based audit gate). */
|
|
100
|
+
export function keywordStepsOf(block: string): KeywordStep[] {
|
|
101
|
+
const out: KeywordStep[] = [];
|
|
102
|
+
for (const line of block.split('\n')) {
|
|
103
|
+
const m = line.match(/^\s*(Given|When|Then|And|But)\s+(.+?)\s*$/);
|
|
104
|
+
if (m) out.push({ keyword: m[1], text: m[2] });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Comment lines of a raw scenario block, with the leading `# ` stripped. */
|
|
110
|
+
export function commentLinesOf(block: string): string[] {
|
|
111
|
+
return block.split('\n').filter((l) => /^\s*#/.test(l)).map((l) => l.replace(/^\s*#\s?/, ''));
|
|
112
|
+
}
|
|
@@ -158,11 +158,14 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
|
|
|
158
158
|
// fields — Setup → precondition (review B-02), Action → trigger, Observable →
|
|
159
159
|
// expected, Oracle → verification method. Labels never remain inside prose.
|
|
160
160
|
const manual = mode === 'manual' && m.feature.comments?.length
|
|
161
|
-
? classifyManualComments(m.feature.comments)
|
|
161
|
+
? classifyManualComments(m.feature.comments, bucketTexts(m.feature.orderedSteps, 'then'))
|
|
162
162
|
: null;
|
|
163
163
|
|
|
164
164
|
// Raw (pre-wording) step texts — these feed shapes + Gate C, never the cells.
|
|
165
|
-
|
|
165
|
+
// A manual with no numbered action lines keeps its Gherkin When steps as the trigger — the
|
|
166
|
+
// same fallback the oracle uses for Then. Taking comment actions only rendered "No action —
|
|
167
|
+
// the state is checked on page load." for a manual whose When clicks something (#810).
|
|
168
|
+
const rawTrigger = manual && manual.actions.length > 0 ? manual.actions : bucketTexts(m.feature.orderedSteps, 'when');
|
|
166
169
|
const rawOracle = manual
|
|
167
170
|
? (manual.expected.length > 0 ? manual.expected : bucketTexts(m.feature.orderedSteps, 'then'))
|
|
168
171
|
: m.resolvedExpected.filter((s) => s.bucket === 'then').map((s) => s.text);
|
|
@@ -593,6 +596,7 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
|
|
|
593
596
|
[v.ref, v.precondition.filter((line) => !commonPre.includes(line))])),
|
|
594
597
|
triggerDeltas: Object.fromEntries(groupVariants.map((v) =>
|
|
595
598
|
[v.ref, v.trigger.slice(prefixLen)])),
|
|
599
|
+
sharedTriggerSteps: prefixLen,
|
|
596
600
|
// Nothing shared to hoist → say what varies rather than leaving the cell blank.
|
|
597
601
|
trigger: prefixLen > 0 || groupVariants.length === 0
|
|
598
602
|
? (first?.trigger ?? []).slice(0, prefixLen)
|
|
@@ -26,6 +26,7 @@ import { renderMatrixXlsx } from './render-xlsx';
|
|
|
26
26
|
import { renderMatrixCsv } from './render-csv';
|
|
27
27
|
import { DeliveryMap, MatrixFinding, MatrixModel } from './types';
|
|
28
28
|
import { ApiCatalogEntry } from '../types';
|
|
29
|
+
import { RunEnvironment } from '../run-environment';
|
|
29
30
|
|
|
30
31
|
export interface MatrixTargetPaths {
|
|
31
32
|
cwd: string;
|
|
@@ -46,6 +47,8 @@ export interface MatrixTargetPaths {
|
|
|
46
47
|
specMdFile?: string;
|
|
47
48
|
resultsPath: string | null;
|
|
48
49
|
mapFile: string;
|
|
50
|
+
/** Executor + environment of the run (#850) — absent for gates-only callers (inspect, approve). */
|
|
51
|
+
runEnv?: RunEnvironment;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
function readSpecText(paths: MatrixTargetPaths): string {
|
|
@@ -91,6 +94,7 @@ export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
|
|
|
91
94
|
specText: readSpecText(paths),
|
|
92
95
|
apiCatalog: catalogFor(paths),
|
|
93
96
|
});
|
|
97
|
+
model.runEnv = paths.runEnv;
|
|
94
98
|
// Perf verdict (#569): budgets are config (qa/perf.yaml), samples are the run's
|
|
95
99
|
// per-test durations. Advisory — reported here because delivery is where runs
|
|
96
100
|
// are read; the audit's score stays a pure function of the design artifacts.
|
|
@@ -102,6 +102,12 @@ function gateASource(ctx: GateContext, findings: MatrixFinding[]): void {
|
|
|
102
102
|
: `scenario ${vpId} has no Then step — a variant without an observable oracle cannot be delivered`,
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
+
if (m.unlabeledManualOracle) {
|
|
106
|
+
findings.push({
|
|
107
|
+
gate: 'A', severity: 'warning', ref: vpId,
|
|
108
|
+
message: `manual oracle found only in an unlabeled comment — exported verbatim into Expected; write the \`# Tester verifies:\` block as numbered "N. Observable: …" lines`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
105
111
|
}
|
|
106
112
|
for (const [vpId, n] of seen) {
|
|
107
113
|
if (n > 1) findings.push({ gate: 'A', severity: 'error', ref: vpId, message: `duplicate VP-id ${vpId} (${n} scenarios)` });
|
|
@@ -17,6 +17,7 @@ import * as ExcelJS from 'exceljs';
|
|
|
17
17
|
import { renderReportHeaderBand, ARIAL_FONT } from '../xlsx-report-builder';
|
|
18
18
|
import { redactSecrets } from '../api-testcase-formatter';
|
|
19
19
|
import { clipNoteField } from '../playwright-report-parser';
|
|
20
|
+
import { runCells } from '../run-environment';
|
|
20
21
|
import { variantState } from './build';
|
|
21
22
|
import { DeliveryItem, MatrixModel } from './types';
|
|
22
23
|
import { SOFT_BLUE, DRAFT_RED, RESULT_STATES, dataCell, headerCell, draftBanner } from './xlsx-cells';
|
|
@@ -42,6 +43,9 @@ export const RESULT_COL = 12;
|
|
|
42
43
|
|
|
43
44
|
/** Variant sub-row — only what distinguishes it from its parent (review GAP-01). */
|
|
44
45
|
function variantValues(item: DeliveryItem, v: DeliveryItem['variants'][number], model: MatrixModel): ExcelJS.CellValue[] {
|
|
46
|
+
// Who ran it and against what — the same cells the legacy workbook fills (#850); blank
|
|
47
|
+
// until the variant has actually run.
|
|
48
|
+
const run = runCells(model.runEnv, v.result);
|
|
45
49
|
return [
|
|
46
50
|
v.ref,
|
|
47
51
|
'',
|
|
@@ -50,15 +54,17 @@ function variantValues(item: DeliveryItem, v: DeliveryItem['variants'][number],
|
|
|
50
54
|
numbered(item.preconditionDeltas[v.ref] ?? []),
|
|
51
55
|
numbered(v.data),
|
|
52
56
|
// Only the steps after the parent's shared prefix, numbered so the parent's
|
|
53
|
-
// steps + these read as one continuous procedure.
|
|
54
|
-
|
|
57
|
+
// steps + these read as one continuous procedure. Counted from the REAL shared
|
|
58
|
+
// steps, not `trigger.length`: with nothing shared the parent shows a generated
|
|
59
|
+
// "the action differs…" note, and the variant's first step read "2." (#848).
|
|
60
|
+
numbered(item.triggerDeltas[v.ref] ?? [], item.sharedTriggerSteps + 1),
|
|
55
61
|
expectedWithVerification(v.oracle, v.verification),
|
|
56
62
|
v.traces.join(', '),
|
|
57
63
|
variantTypeLabel(v),
|
|
58
64
|
variantState(v),
|
|
59
65
|
isoDate(v.result?.startTime),
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
run.executor,
|
|
67
|
+
run.environment,
|
|
62
68
|
// Redact BEFORE clipping — same denylist as the testcases CSV Note column.
|
|
63
69
|
v.result?.error ? clipNoteField(redactSecrets(String(v.result.error), model.secretValues)) : '',
|
|
64
70
|
];
|
|
@@ -176,8 +176,12 @@ export interface DeliveryItem {
|
|
|
176
176
|
/** Preconditions shared by EVERY variant (the intersection) — written once here. */
|
|
177
177
|
precondition: string[];
|
|
178
178
|
/** Leading trigger steps shared by every variant — written once here; each
|
|
179
|
-
* variant renders only its remaining steps (`triggerDeltas`).
|
|
179
|
+
* variant renders only its remaining steps (`triggerDeltas`). When nothing is shared this
|
|
180
|
+
* holds a generated "the action differs…" note instead, which is NOT a step. */
|
|
180
181
|
trigger: string[];
|
|
182
|
+
/** How many of `trigger` are real steps hoisted from the variants (0 when it is only the
|
|
183
|
+
* note) — the variants' steps are numbered from this + 1 (#848). */
|
|
184
|
+
sharedTriggerSteps: number;
|
|
181
185
|
variants: CoverageVariant[];
|
|
182
186
|
/** variant ref → its precondition lines that are not on the parent. */
|
|
183
187
|
preconditionDeltas: Record<string, string[]>;
|
|
@@ -253,6 +257,9 @@ export interface MatrixModel {
|
|
|
253
257
|
* the deliverable ships to the client.
|
|
254
258
|
*/
|
|
255
259
|
secretValues: string[];
|
|
260
|
+
/** Who ran the suite and against what — fills Test Executor / Test Environment on rows that
|
|
261
|
+
* ran (#850). Absent when the caller has no run context (gates-only paths). */
|
|
262
|
+
runEnv?: import('../run-environment').RunEnvironment;
|
|
256
263
|
}
|
|
257
264
|
|
|
258
265
|
/** Complexity warning threshold (rules draft Gate I; raised for compact grouping) — echoed in the manifest. */
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* Action → action, Observable → expected, Oracle → verification method.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { extractUnlabeledOracle, onlyPresenceAssertions } from '../manual-hybrid-oracle';
|
|
17
|
+
|
|
16
18
|
/** A step that CAPTURES a runtime value: `User remember [X] text as {{var}}`. */
|
|
17
19
|
export const RUNTIME_CAPTURE = /\bremember\b/i;
|
|
18
20
|
|
|
@@ -229,7 +231,7 @@ export interface ManualProcedure {
|
|
|
229
231
|
* review feedback §10.2(3). Continuation lines append to the previous item;
|
|
230
232
|
* pre-amble (rationale/header/dividers) is skipped.
|
|
231
233
|
*/
|
|
232
|
-
export function classifyManualComments(comments: string[]): ManualProcedure {
|
|
234
|
+
export function classifyManualComments(comments: string[], thenTexts: string[] = []): ManualProcedure {
|
|
233
235
|
const out: ManualProcedure = { preconditions: [], actions: [], expected: [], verification: [] };
|
|
234
236
|
let last: { list: string[]; idx: number } | null = null;
|
|
235
237
|
|
|
@@ -255,5 +257,12 @@ export function classifyManualComments(comments: string[]): ManualProcedure {
|
|
|
255
257
|
last.list[last.idx] += ' ' + line;
|
|
256
258
|
}
|
|
257
259
|
}
|
|
260
|
+
// #810 — an oracle written as unlabelled `Tester verifies:` prose is still the oracle: with no
|
|
261
|
+
// labelled Observable line and no Then beyond presence (`thenTexts`), export it rather than fall
|
|
262
|
+
// back to a presence-placeholder Then. A real Then stays the oracle.
|
|
263
|
+
if (!out.expected.length && onlyPresenceAssertions(thenTexts)) {
|
|
264
|
+
const prose = extractUnlabeledOracle(comments);
|
|
265
|
+
if (prose) out.expected.push(prose);
|
|
266
|
+
}
|
|
258
267
|
return out;
|
|
259
268
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who ran the tests, and against what — the Test Executor / Test Environment columns of every
|
|
3
|
+
* deliverable (legacy workbook, matrix workbook) and the dashboard.
|
|
4
|
+
*
|
|
5
|
+
* One module so the surfaces cannot drift: the matrix format shipped with both cells hard-coded
|
|
6
|
+
* blank on every Auto row while the legacy exporter filled them (#850).
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import { resolveWebBaseUrl } from './env-qa';
|
|
12
|
+
import { statusToTestResult } from './playwright-report-parser';
|
|
13
|
+
import { EnvironmentInfo, PlaywrightResult } from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The executor, most explicit source first: `SUNGEN_EXECUTOR` (a CI job names the person or
|
|
17
|
+
* pipeline that ran the suite — whoever runs `sungen delivery` there is not that person), then
|
|
18
|
+
* git user.name, then `CI_USER` / `USER`.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveExecutor(cwd: string): string {
|
|
21
|
+
const explicit = process.env.SUNGEN_EXECUTOR?.trim();
|
|
22
|
+
if (explicit) return explicit;
|
|
23
|
+
try {
|
|
24
|
+
const gitUser = execSync('git config user.name', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
25
|
+
if (gitUser) return gitUser;
|
|
26
|
+
} catch { /* not a git checkout, or no user.name */ }
|
|
27
|
+
return process.env.CI_USER || process.env.USER || '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** baseURL + browser project from playwright.config.ts, executor from {@link resolveExecutor}. */
|
|
31
|
+
export function getEnvironment(cwd: string): EnvironmentInfo {
|
|
32
|
+
let baseURL = '';
|
|
33
|
+
let projectName = 'chromium';
|
|
34
|
+
const configPath = path.join(cwd, 'playwright.config.ts');
|
|
35
|
+
if (fs.existsSync(configPath)) {
|
|
36
|
+
const c = fs.readFileSync(configPath, 'utf-8');
|
|
37
|
+
// Every config shape sungen has scaffolded: the literal `baseURL: 'x'`, the env-aware
|
|
38
|
+
// `baseURL: resolveBaseUrl('x')`, and the older `baseURL: process.env.SUNGEN_BASE_URL || 'x'`
|
|
39
|
+
// (the quoted value is the static fallback in both wrappers).
|
|
40
|
+
const baseMatch = c.match(/baseURL:\s*(?:resolveBaseUrl\(\s*|process\.env\.\w+\s*\|\|\s*)?['"]([^'"]+)['"]/);
|
|
41
|
+
if (baseMatch) baseURL = baseMatch[1];
|
|
42
|
+
const projMatch = c.match(/name:\s*['"]([^'"]+)['"]/);
|
|
43
|
+
if (projMatch) projectName = projMatch[1];
|
|
44
|
+
}
|
|
45
|
+
// SUNGEN_BASE_URL (shell > .env.qa.<SUNGEN_ENV> > .env.qa) overrides the static
|
|
46
|
+
// value — the Test Environment column must show the host the run actually hit.
|
|
47
|
+
baseURL = resolveWebBaseUrl(cwd, baseURL);
|
|
48
|
+
return { baseURL, projectName, executor: resolveExecutor(cwd) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `https://host/path (project)` — or, on mobile (no baseURL), the bare app id `(project)`. */
|
|
52
|
+
export function formatEnv(baseURL: string, featurePath: string | undefined, projectName: string): string {
|
|
53
|
+
const base = (baseURL || '').replace(/\/+$/, '');
|
|
54
|
+
const p = (featurePath || '').replace(/^\/+/, '');
|
|
55
|
+
// Join with '/' only when there is a baseURL (web). For mobile baseURL is empty
|
|
56
|
+
// and `p` is an app id (e.g. com.kngroup.media.converter) — emit it bare, no '/'.
|
|
57
|
+
const url = base && p ? `${base}/${p}` : (p || base);
|
|
58
|
+
return `${url} (${projectName})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The platform a result file belongs to — `<f>-test-result.<os>[.<env>].json` → `<os>`. It names
|
|
63
|
+
* the run actually being read, so it outranks `MOBILE_PLATFORM` / `SUNGEN_ENV`, and SUNGEN_ENV
|
|
64
|
+
* is usually an environment (`staging`, `ja`), not an OS. A web locale file yields its locale,
|
|
65
|
+
* which is harmless: only a dual-id mobile `Path:` (`android | ios`) reads the platform.
|
|
66
|
+
*/
|
|
67
|
+
export function platformOfResultFile(resultsPath: string | null | undefined): string | undefined {
|
|
68
|
+
return resultsPath ? path.basename(resultsPath).match(/-test-result\.([^.]+)\./)?.[1] : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a run-result row needs to fill its executor/environment cells. */
|
|
72
|
+
export interface RunEnvironment extends EnvironmentInfo {
|
|
73
|
+
/** The unit's `Path:` (flows: their page selector; mobile: the OS-precise app id). */
|
|
74
|
+
featurePath?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The Test Executor / Test Environment cells for one result — blank unless the test actually
|
|
79
|
+
* ran (a Pending or N/A row has nobody who executed it and no environment it ran against).
|
|
80
|
+
*/
|
|
81
|
+
export function runCells(env: RunEnvironment | undefined, result: PlaywrightResult | undefined): { executor: string; environment: string } {
|
|
82
|
+
if (!env || !result) return { executor: '', environment: '' };
|
|
83
|
+
const outcome = statusToTestResult(result.status);
|
|
84
|
+
if (outcome === 'Pending' || outcome === 'N/A') return { executor: '', environment: '' };
|
|
85
|
+
return {
|
|
86
|
+
executor: env.executor,
|
|
87
|
+
environment: formatEnv(env.baseURL, env.featurePath, result.projectName || env.projectName),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { FeatureMetadata, OrderedStep, ScenarioMetadata, SpecFileData, SpecTest } from './types';
|
|
12
12
|
import { isStepsBaseScenario, isSampleScaffoldScenario, extractTestcaseType } from './feature-parser';
|
|
13
13
|
import { SCENARIO_ID } from './feature-parser';
|
|
14
|
+
import { extractUnlabeledOracle, onlyPresenceAssertions } from './manual-hybrid-oracle';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Parse a @manual scenario's `# Tester verifies:` comment block into procedure vs
|
|
@@ -21,8 +22,12 @@ import { SCENARIO_ID } from './feature-parser';
|
|
|
21
22
|
* (Setup/Action/Precondition/unlabelled) → Steps. A non-numbered line following a
|
|
22
23
|
* numbered item is a continuation (e.g. a wrapped URL) and is appended to it. Pre-amble
|
|
23
24
|
* lines (the `MANUAL:` rationale, the `Tester verifies:` header, separators) are ignored.
|
|
25
|
+
*
|
|
26
|
+
* `unlabeledOracle` is the prose a `Tester verifies:` header carries OUTSIDE numbered lines
|
|
27
|
+
* (#810). It is not part of `expected` — the gates judge the numbered shape — but the exporter
|
|
28
|
+
* must never drop it: when no labelled oracle line exists it becomes the Expected cell.
|
|
24
29
|
*/
|
|
25
|
-
export function parseManualComments(comments: string[]): { steps: OrderedStep[]; expected: OrderedStep[] } {
|
|
30
|
+
export function parseManualComments(comments: string[]): { steps: OrderedStep[]; expected: OrderedStep[]; unlabeledOracle: string | null } {
|
|
26
31
|
const steps: OrderedStep[] = [];
|
|
27
32
|
const expected: OrderedStep[] = [];
|
|
28
33
|
let last: OrderedStep | null = null;
|
|
@@ -45,7 +50,31 @@ export function parseManualComments(comments: string[]): { steps: OrderedStep[];
|
|
|
45
50
|
}
|
|
46
51
|
// else: pre-amble before any numbered item (rationale / header) → skip
|
|
47
52
|
}
|
|
48
|
-
return { steps, expected };
|
|
53
|
+
return { steps, expected, unlabeledOracle: extractUnlabeledOracle(comments) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The reason a @manual scenario is manual — the text of its `# MANUAL…: <reason>` comment
|
|
58
|
+
* (`MANUAL:`, `MANUAL (2026-09-16, run-test):`, `MANUAL/ENV:` …) plus its indented continuation
|
|
59
|
+
* lines, up to the `Tester verifies:` header, a numbered item or a divider. '' when absent.
|
|
60
|
+
* Rendered in the deliverable's Note so a tester sees why the case is not automated (#810).
|
|
61
|
+
*/
|
|
62
|
+
export function extractManualReason(comments: string[]): string {
|
|
63
|
+
const parts: string[] = [];
|
|
64
|
+
let inReason = false;
|
|
65
|
+
for (const raw of comments) {
|
|
66
|
+
const line = raw.trim();
|
|
67
|
+
if (!inReason) {
|
|
68
|
+
const m = line.match(/^MANUAL\b[^:]*:\s*(.*)$/i);
|
|
69
|
+
if (m) { inReason = true; if (m[1].trim()) parts.push(m[1].trim()); }
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// A wrapped reason continues on an INDENTED comment line (`# automate once seeded.`); the
|
|
73
|
+
// comment list carries no line numbers, so an unindented line is a separate note, not more reason.
|
|
74
|
+
if (!line || !/^\s/.test(raw) || /^tester verifies\b/i.test(line) || /^\d+[.)]\s/.test(line) || /^[-=*_]{2,}/.test(line)) break;
|
|
75
|
+
parts.push(line);
|
|
76
|
+
}
|
|
77
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
export interface MergedScenario {
|
|
@@ -84,6 +113,12 @@ export interface MergedScenario {
|
|
|
84
113
|
* so counting them would falsely mark @manual scenarios as interleaved.
|
|
85
114
|
*/
|
|
86
115
|
resolvedAnchorActionCount: number;
|
|
116
|
+
/**
|
|
117
|
+
* Set when this @manual scenario's oracle exists ONLY as unlabelled `Tester verifies:` prose
|
|
118
|
+
* (#810). The prose is exported verbatim into `resolvedExpected`; delivery warns so the author
|
|
119
|
+
* rewrites it as numbered `Observable:` lines.
|
|
120
|
+
*/
|
|
121
|
+
unlabeledManualOracle?: string;
|
|
87
122
|
}
|
|
88
123
|
|
|
89
124
|
/**
|
|
@@ -147,7 +182,16 @@ export function mergeFeatureAndSpec(
|
|
|
147
182
|
const manualParsed =
|
|
148
183
|
extractTestcaseType(scenario.tags) === 'Manual' && scenario.comments?.length
|
|
149
184
|
? parseManualComments(scenario.comments)
|
|
150
|
-
: { steps: [] as OrderedStep[], expected: [] as OrderedStep[] };
|
|
185
|
+
: { steps: [] as OrderedStep[], expected: [] as OrderedStep[], unlabeledOracle: null };
|
|
186
|
+
// Never drop a manual oracle (#810): with no labelled Observable/Oracle line and no Then that
|
|
187
|
+
// checks more than presence, the unlabelled `Tester verifies:` prose IS what the case checks —
|
|
188
|
+
// export it rather than let a presence placeholder stand in for it. A real Then keeps the cell.
|
|
189
|
+
const unlabeledOracle = !manualParsed.expected.length && onlyPresenceAssertions(ownAssertions.map((s) => s.text))
|
|
190
|
+
? manualParsed.unlabeledOracle
|
|
191
|
+
: null;
|
|
192
|
+
const manualExpected: OrderedStep[] = unlabeledOracle
|
|
193
|
+
? [{ text: unlabeledOracle, bucket: 'then' }]
|
|
194
|
+
: manualParsed.expected;
|
|
151
195
|
|
|
152
196
|
result.push({
|
|
153
197
|
feature: scenario,
|
|
@@ -158,8 +202,9 @@ export function mergeFeatureAndSpec(
|
|
|
158
202
|
...ownActions,
|
|
159
203
|
...manualParsed.steps,
|
|
160
204
|
],
|
|
161
|
-
resolvedExpected: [...ownAssertions, ...
|
|
205
|
+
resolvedExpected: [...ownAssertions, ...manualExpected],
|
|
162
206
|
resolvedAnchorActionCount: baseLen + ownActions.length,
|
|
207
|
+
...(unlabeledOracle ? { unlabeledManualOracle: unlabeledOracle } : {}),
|
|
163
208
|
});
|
|
164
209
|
}
|
|
165
210
|
|
|
@@ -190,9 +190,3 @@ export function renderDynamicTokens(text: string): string {
|
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
/**
|
|
194
|
-
* Resolve the test-data file path for a given screen.
|
|
195
|
-
*/
|
|
196
|
-
export function resolveTestDataPath(cwd: string, screen: string): string {
|
|
197
|
-
return path.join(cwd, 'qa', 'screens', screen, 'test-data', `${screen}.yaml`);
|
|
198
|
-
}
|
|
@@ -154,11 +154,18 @@ export class GherkinParser {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* and before scenario i+1's header (its first tag line, or its `Scenario:` line). The
|
|
160
|
-
* leading `# ` is stripped. Used to surface a @manual scenario's tester-procedure comments
|
|
157
|
+
* Attach top-level Gherkin comments to the scenario they describe. The leading `# ` is
|
|
158
|
+
* stripped. Used to surface a @manual scenario's tester procedure and `# MANUAL:` reason
|
|
161
159
|
* (its only "steps") in the delivery report.
|
|
160
|
+
*
|
|
161
|
+
* A scenario owns:
|
|
162
|
+
* • its LEADING block — the comment lines directly above its header with no blank line in
|
|
163
|
+
* between (and any comment between its tag lines and `Scenario:`), as long as they sit
|
|
164
|
+
* after the previous scenario's last step. `# MANUAL (…): reason` written above the tags
|
|
165
|
+
* belongs to the scenario below it; it used to range onto the scenario ABOVE, where the
|
|
166
|
+
* manual parser appended it to that scenario's last oracle line (#810);
|
|
167
|
+
* • its BODY — every other comment after its `Scenario:` line and before the next
|
|
168
|
+
* scenario's leading block / header.
|
|
162
169
|
*/
|
|
163
170
|
private attachScenarioComments(
|
|
164
171
|
doc: Messages.GherkinDocument,
|
|
@@ -167,22 +174,32 @@ export class GherkinParser {
|
|
|
167
174
|
): void {
|
|
168
175
|
const comments = doc.comments || [];
|
|
169
176
|
if (!comments.length) return;
|
|
170
|
-
const
|
|
171
|
-
.
|
|
172
|
-
|
|
177
|
+
const lineOf = new Map<number, string>(
|
|
178
|
+
comments.map((cm) => [cm.location.line, cm.text.replace(/^\s*#\s?/, '').replace(/\s+$/, '')]),
|
|
179
|
+
);
|
|
173
180
|
// Same filter/order as parseScenarios → indices line up with `scenarios`.
|
|
181
|
+
const scenChildren = feature.children.filter((c) => c.scenario).map((c) => c.scenario!);
|
|
182
|
+
const background = feature.children.find((c) => c.background)?.background;
|
|
183
|
+
const headerLine = (sc: Messages.Scenario): number =>
|
|
184
|
+
Math.min(sc.location.line, ...(sc.tags || []).map((t) => t.location.line));
|
|
185
|
+
|
|
186
|
+
// Leading block of scenario i: [firstLeading, sc.location.line).
|
|
187
|
+
const leadingStart = scenChildren.map((sc, i) => {
|
|
188
|
+
const prevEnd = i > 0 ? bodyEndLine(scenChildren[i - 1]) : background ? bodyEndLine(background) : feature.location.line;
|
|
189
|
+
let first = headerLine(sc);
|
|
190
|
+
while (lineOf.has(first - 1) && first - 1 > prevEnd) first--;
|
|
191
|
+
return first;
|
|
192
|
+
});
|
|
193
|
+
|
|
174
194
|
for (let i = 0; i < scenChildren.length && i < scenarios.length; i++) {
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
.filter((cm) => cm.location.line > startLine && cm.location.line < endLine)
|
|
184
|
-
.map((cm) => cm.text.replace(/^\s*#\s?/, '').replace(/\s+$/, ''));
|
|
185
|
-
if (body.length) scenarios[i].comments = body;
|
|
195
|
+
const from = leadingStart[i];
|
|
196
|
+
const to = i + 1 < scenChildren.length ? leadingStart[i + 1] : Number.POSITIVE_INFINITY;
|
|
197
|
+
const own = comments
|
|
198
|
+
.map((cm) => cm.location.line)
|
|
199
|
+
.filter((line) => line >= from && line < to)
|
|
200
|
+
.sort((a, b) => a - b)
|
|
201
|
+
.map((line) => lineOf.get(line)!);
|
|
202
|
+
if (own.length) scenarios[i].comments = own;
|
|
186
203
|
}
|
|
187
204
|
}
|
|
188
205
|
|
|
@@ -360,3 +377,18 @@ export class GherkinParser {
|
|
|
360
377
|
return files;
|
|
361
378
|
}
|
|
362
379
|
}
|
|
380
|
+
|
|
381
|
+
/** Last source line a scenario/background occupies: its steps, their data tables/doc strings, examples. */
|
|
382
|
+
function bodyEndLine(block: Messages.Scenario | Messages.Background): number {
|
|
383
|
+
let end = block.location.line;
|
|
384
|
+
for (const step of block.steps) {
|
|
385
|
+
end = Math.max(end, step.location.line);
|
|
386
|
+
for (const row of step.dataTable?.rows ?? []) end = Math.max(end, row.location.line);
|
|
387
|
+
if (step.docString) end = Math.max(end, step.docString.location.line + step.docString.content.split('\n').length + 1);
|
|
388
|
+
}
|
|
389
|
+
for (const ex of ('examples' in block ? block.examples : [])) {
|
|
390
|
+
end = Math.max(end, ex.location.line);
|
|
391
|
+
for (const row of [...(ex.tableHeader ? [ex.tableHeader] : []), ...ex.tableBody]) end = Math.max(end, row.location.line);
|
|
392
|
+
}
|
|
393
|
+
return end;
|
|
394
|
+
}
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
export interface AuthGroup {
|
|
7
7
|
authRole?: string;
|
|
8
|
+
noAuth?: boolean;
|
|
8
9
|
scenarios: string[];
|
|
10
|
+
/** The feature's `@afterEach`, scoped to this group — set when the feature mixes `@no-auth` with
|
|
11
|
+
* other groups, so the hook never runs on an anonymous test's empty session (#815). */
|
|
12
|
+
afterEach?: string;
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
/**
|