@sun-asterisk/sungen 3.2.34 → 3.2.35
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.map +1 -1
- package/dist/cli/commands/audit.js +9 -4
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/capability.d.ts.map +1 -1
- package/dist/cli/commands/capability.js +5 -1
- package/dist/cli/commands/capability.js.map +1 -1
- package/dist/cli/commands/defect.d.ts +7 -0
- package/dist/cli/commands/defect.d.ts.map +1 -0
- package/dist/cli/commands/defect.js +68 -0
- package/dist/cli/commands/defect.js.map +1 -0
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +35 -7
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/triage.d.ts +7 -0
- package/dist/cli/commands/triage.d.ts.map +1 -0
- package/dist/cli/commands/triage.js +98 -0
- package/dist/cli/commands/triage.js.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- 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 +1 -1
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +8 -0
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +12 -0
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +49 -10
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/carry-manual-results.d.ts +19 -0
- package/dist/exporters/matrix/carry-manual-results.d.ts.map +1 -1
- package/dist/exporters/matrix/carry-manual-results.js +76 -6
- package/dist/exporters/matrix/carry-manual-results.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts +5 -2
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +14 -3
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/render-csv.js +1 -1
- package/dist/exporters/matrix/render-csv.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +3 -0
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +5 -0
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +1 -0
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/scenario-merger.d.ts.map +1 -1
- package/dist/exporters/scenario-merger.js +2 -0
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/test-data-resolver.d.ts.map +1 -1
- package/dist/exporters/test-data-resolver.js +12 -2
- package/dist/exporters/test-data-resolver.js.map +1 -1
- package/dist/exporters/types.d.ts +5 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
- package/dist/generators/gherkin-parser/index.js +1 -0
- package/dist/generators/gherkin-parser/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/contain-text-assertion.hbs +2 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/expanded-assertion.hbs +2 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/date-picker-unsupported.hbs +5 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/fill-action.hbs +13 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/contain-text-assertion.hbs +9 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/expanded-assertion.hbs +12 -0
- package/dist/generators/test-generator/code-generator.js +4 -5
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +9 -3
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +9 -3
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/patterns/types.d.ts +1 -1
- package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts +8 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +52 -8
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.d.ts +16 -0
- package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.js +59 -0
- package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +10 -2
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +18 -5
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/counted-assertions.d.ts +2 -0
- package/dist/harness/counted-assertions.d.ts.map +1 -0
- package/dist/harness/counted-assertions.js +38 -0
- package/dist/harness/counted-assertions.js.map +1 -0
- package/dist/harness/defect-draft.d.ts +14 -0
- package/dist/harness/defect-draft.d.ts.map +1 -0
- package/dist/harness/defect-draft.js +143 -0
- package/dist/harness/defect-draft.js.map +1 -0
- package/dist/harness/defects.d.ts +24 -0
- package/dist/harness/defects.d.ts.map +1 -0
- package/dist/harness/defects.js +118 -0
- package/dist/harness/defects.js.map +1 -0
- package/dist/harness/manifest.d.ts.map +1 -1
- package/dist/harness/manifest.js +2 -2
- package/dist/harness/manifest.js.map +1 -1
- package/dist/harness/next-step.d.ts +0 -4
- package/dist/harness/next-step.d.ts.map +1 -1
- package/dist/harness/next-step.js +34 -4
- package/dist/harness/next-step.js.map +1 -1
- package/dist/harness/parse.d.ts +18 -0
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +20 -11
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/script-check.d.ts.map +1 -1
- package/dist/harness/script-check.js +7 -1
- package/dist/harness/script-check.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +23 -6
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/triage-signals.d.ts +20 -0
- package/dist/harness/triage-signals.d.ts.map +1 -0
- package/dist/harness/triage-signals.js +30 -0
- package/dist/harness/triage-signals.js.map +1 -0
- package/dist/harness/triage-unit.d.ts +23 -0
- package/dist/harness/triage-unit.d.ts.map +1 -0
- package/dist/harness/triage-unit.js +142 -0
- package/dist/harness/triage-unit.js.map +1 -0
- package/dist/harness/triage.d.ts +28 -0
- package/dist/harness/triage.d.ts.map +1 -0
- package/dist/harness/triage.js +99 -0
- package/dist/harness/triage.js.map +1 -0
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +2 -1
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/ai-skills/commands.d.ts.map +1 -1
- package/dist/orchestrator/ai-skills/commands.js +5 -3
- package/dist/orchestrator/ai-skills/commands.js.map +1 -1
- package/dist/orchestrator/ai-skills/golden-skills.test.js +29 -0
- package/dist/orchestrator/ai-skills/golden-skills.test.js.map +1 -1
- package/dist/orchestrator/screen-manager.d.ts.map +1 -1
- package/dist/orchestrator/screen-manager.js +4 -2
- package/dist/orchestrator/screen-manager.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-flow.md +26 -4
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +8 -2
- package/dist/orchestrator/templates/ai-src/config/claude.md +20 -7
- package/dist/orchestrator/templates/ai-src/config/copilot.md +20 -7
- package/dist/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-capture-mobile/SKILL.md +3 -3
- package/dist/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +9 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +7 -4
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +25 -9
- package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +2 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +4 -3
- package/dist/orchestrator/templates/specs-test-data.ts +22 -4
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +9 -4
- package/src/cli/commands/capability.ts +3 -1
- package/src/cli/commands/defect.ts +49 -0
- package/src/cli/commands/delivery.ts +33 -10
- package/src/cli/commands/triage.ts +57 -0
- package/src/cli/index.ts +4 -0
- package/src/exporters/csv-exporter.ts +5 -1
- package/src/exporters/feature-parser.ts +12 -0
- package/src/exporters/matrix/build.ts +51 -9
- package/src/exporters/matrix/carry-manual-results.ts +70 -7
- package/src/exporters/matrix/export.ts +12 -2
- package/src/exporters/matrix/render-csv.ts +1 -1
- package/src/exporters/matrix/render-xlsx.ts +3 -0
- package/src/exporters/matrix/types.ts +5 -0
- package/src/exporters/playwright-report-parser.ts +1 -0
- package/src/exporters/scenario-merger.ts +2 -1
- package/src/exporters/test-data-resolver.ts +10 -2
- package/src/exporters/types.ts +5 -0
- package/src/generators/gherkin-parser/index.ts +1 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/contain-text-assertion.hbs +2 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/expanded-assertion.hbs +2 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/date-picker-unsupported.hbs +5 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/fill-action.hbs +13 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/contain-text-assertion.hbs +9 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/expanded-assertion.hbs +12 -0
- package/src/generators/test-generator/code-generator.ts +4 -4
- package/src/generators/test-generator/diagnostics.ts +9 -3
- package/src/generators/test-generator/patterns/types.ts +2 -0
- package/src/generators/test-generator/step-mapper.ts +56 -9
- package/src/generators/test-generator/utils/data-resolver.ts +53 -0
- package/src/generators/test-generator/utils/selector-resolver.ts +10 -2
- package/src/harness/audit.ts +19 -6
- package/src/harness/counted-assertions.ts +33 -0
- package/src/harness/defect-draft.ts +115 -0
- package/src/harness/defects.ts +94 -0
- package/src/harness/manifest.ts +2 -2
- package/src/harness/next-step.ts +29 -4
- package/src/harness/parse.ts +20 -11
- package/src/harness/script-check.ts +7 -1
- package/src/harness/sensors.ts +26 -7
- package/src/harness/triage-signals.ts +46 -0
- package/src/harness/triage-unit.ts +113 -0
- package/src/harness/triage.ts +127 -0
- package/src/harness/viewpoint-ledger.ts +2 -2
- package/src/index.ts +2 -1
- package/src/orchestrator/ai-skills/commands.ts +5 -3
- package/src/orchestrator/ai-skills/golden-skills.test.ts +23 -0
- package/src/orchestrator/screen-manager.ts +3 -1
- package/src/orchestrator/templates/ai-src/commands/add-flow.md +26 -4
- package/src/orchestrator/templates/ai-src/commands/run-test.md +8 -2
- package/src/orchestrator/templates/ai-src/config/claude.md +20 -7
- package/src/orchestrator/templates/ai-src/config/copilot.md +20 -7
- package/src/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-capture-mobile/SKILL.md +3 -3
- package/src/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +9 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +7 -4
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +25 -9
- package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +2 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +4 -3
- package/src/orchestrator/templates/specs-test-data.ts +22 -4
|
@@ -161,6 +161,7 @@ export function loadPlaywrightReport(reportPath: string): Map<string, Playwright
|
|
|
161
161
|
errorMessages: distinct.length > 0 ? distinct : undefined,
|
|
162
162
|
tracePath: trace,
|
|
163
163
|
projectName: test?.projectName,
|
|
164
|
+
attempts: results.length || undefined,
|
|
164
165
|
});
|
|
165
166
|
}
|
|
166
167
|
return result;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { FeatureMetadata, OrderedStep, ScenarioMetadata, SpecFileData, SpecTest } from './types';
|
|
12
|
-
import { isStepsBaseScenario, isSampleScaffoldScenario, extractTestcaseType } from './feature-parser';
|
|
12
|
+
import { isStepsBaseScenario, isSampleScaffoldScenario, isHookScenario, extractTestcaseType } from './feature-parser';
|
|
13
13
|
import { SCENARIO_ID } from './feature-parser';
|
|
14
14
|
import { extractUnlabeledOracle, onlyPresenceAssertions } from './manual-hybrid-oracle';
|
|
15
15
|
|
|
@@ -139,6 +139,7 @@ export function mergeFeatureAndSpec(
|
|
|
139
139
|
for (const scenario of feature.scenarios) {
|
|
140
140
|
if (isStepsBaseScenario(scenario)) continue; // skip @steps base scenarios
|
|
141
141
|
if (isSampleScaffoldScenario(scenario)) continue; // skip default sample
|
|
142
|
+
if (isHookScenario(scenario)) continue; // skip @beforeAll/@afterEach/@afterAll hooks
|
|
142
143
|
|
|
143
144
|
const specTest = findMatchingSpecTest(scenario, spec.tests, feature.scenarios);
|
|
144
145
|
const base = scenario.extendsName ? stepsBaseByName.get(scenario.extendsName) : undefined;
|
|
@@ -183,8 +183,16 @@ export function renderDynamicTokens(text: string): string {
|
|
|
183
183
|
const [min, max] = (args || '1:9999').split(':');
|
|
184
184
|
return `<random ${min}-${max}>`;
|
|
185
185
|
}
|
|
186
|
-
case 'date':
|
|
187
|
-
|
|
186
|
+
case 'date': {
|
|
187
|
+
if (!args) return "<today's date>";
|
|
188
|
+
// Same argument grammar as the runtime resolver (specs-test-data.ts), which leaves anything
|
|
189
|
+
// else raw — so a raw token here is what the tester would see too, and Gate D flags it.
|
|
190
|
+
const off = /^([+-]\d{1,5})d$/.exec(args.trim());
|
|
191
|
+
if (!off) return m;
|
|
192
|
+
const n = Number(off[1]);
|
|
193
|
+
return `<today's date ${n < 0 ? '-' : '+'} ${Math.abs(n)} day${Math.abs(n) === 1 ? '' : 's'}>`;
|
|
194
|
+
}
|
|
195
|
+
case 'datetime': return args ? m : '<current date-time>';
|
|
188
196
|
default: return m;
|
|
189
197
|
}
|
|
190
198
|
});
|
package/src/exporters/types.ts
CHANGED
|
@@ -95,6 +95,9 @@ export interface ScenarioMetadata {
|
|
|
95
95
|
* as Gherkin steps — the merger parses these into Steps/Expected for the report.
|
|
96
96
|
*/
|
|
97
97
|
comments?: string[];
|
|
98
|
+
/** Set for a `@beforeAll` / `@afterEach` / `@afterAll` scenario — a hook the compiler emits as
|
|
99
|
+
* `test.beforeAll()` etc., never a test case (#880). */
|
|
100
|
+
hookType?: 'beforeAll' | 'afterEach' | 'afterAll';
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
/**
|
|
@@ -134,6 +137,8 @@ export interface PlaywrightResult {
|
|
|
134
137
|
errorMessages?: string[];
|
|
135
138
|
tracePath?: string;
|
|
136
139
|
projectName?: string;
|
|
140
|
+
/** How many attempts the test took (retries + 1). A pass with attempts > 1 is flaky. */
|
|
141
|
+
attempts?: number;
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
/**
|
|
@@ -18,6 +18,7 @@ export const STATE_CLAUSES: ReadonlyArray<StateClause> = [
|
|
|
18
18
|
'sorted ascending', 'sorted descending',
|
|
19
19
|
'unchecked', 'checked',
|
|
20
20
|
'hidden', 'visible', 'disabled', 'enabled', 'focused', 'empty', 'loading', 'selected',
|
|
21
|
+
'expanded', 'collapsed',
|
|
21
22
|
];
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
? [await __el.getAttribute('label'), await __el.getAttribute('value')]
|
|
9
9
|
: [await __el.getText(), await __el.getAttribute('content-desc')];
|
|
10
10
|
const __actual = String(__sources.find(s => s && String(s).trim() !== '') ?? '').trim();
|
|
11
|
-
|
|
11
|
+
{{!-- An empty expected value means "shows no text": includes('') holds for any text (#910). --}}
|
|
12
|
+
if ({{#if emptyMeansEmpty}}__expected === '' ? __actual !== '' : {{/if}}!__actual.includes(__expected)) {
|
|
12
13
|
throw new Error(`[sungen] Expected text containing "${__expected}" but element reads "${__actual}"`);
|
|
13
14
|
}
|
|
14
15
|
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{{!-- aria-expanded has no native-mobile equivalent; the appium expand action is a bare tap. --}}
|
|
2
|
+
throw new Error("[sungen] 'is expanded' / 'is collapsed' (aria-expanded) is web-only and has no native-mobile equivalent. Assert what the open panel shows instead (e.g. see [X] section), or tag this scenario @manual.");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{{!-- A date-picker whose trigger is a <button> (a calendar popover, #907) has no value to type:
|
|
2
|
+
fill() throws "Element is not an <input>". With no recipe there is no honest compilation, so
|
|
3
|
+
fail loud and name the way forward — the appium twin of this template does the same for a
|
|
4
|
+
native picker dialog. --}}
|
|
5
|
+
throw new Error('[sungen] {{escapeQuotes reason}}');
|
package/src/generators/test-generator/adapters/playwright/templates/steps/actions/fill-action.hbs
CHANGED
|
@@ -1 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
{{#if guardFillable~}}
|
|
2
|
+
{{!-- A recipe-less date-picker / button / link: Playwright's own "Element is not an <input>" names
|
|
3
|
+
no fix when the target is a trigger that opens a calendar popup. Keep Playwright's semantics
|
|
4
|
+
(it also fills a <label>'s control or a contenteditable child) and only re-word that one
|
|
5
|
+
failure with the way forward (#907). --}}
|
|
6
|
+
try {
|
|
7
|
+
await {{> locator}}.fill('{{escapeQuotes fillValue}}');
|
|
8
|
+
} catch (e) {
|
|
9
|
+
if (!/is not an <input>/.test(String(e))) throw e;
|
|
10
|
+
throw new Error('[sungen] [{{escapeQuotes selectorRef}}] is not a fillable element (a button or popup trigger opens a picker). Declare its choreography as a recipe with a fill intent (and an assertValue intent) in qa/app.yaml and point the selector at it with variant: <recipe>, or widgets.datepicker for every date-picker.');
|
|
11
|
+
}
|
|
12
|
+
{{else~}}
|
|
13
|
+
await {{> locator}}.fill('{{escapeQuotes fillValue}}');{{/if}}
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
{{#if emptyMeansEmpty~}}
|
|
2
|
+
{{!-- toContainText('') passes for ANY text — the empty string is a substring of everything — so an
|
|
3
|
+
"is empty" check would assert nothing. An empty expected value means the element shows no text (#910). --}}
|
|
4
|
+
{ const __expected = '{{escapeQuotes expectedText}}';
|
|
5
|
+
if (__expected === '') await expect({{> locator}}).toHaveText('');
|
|
6
|
+
else await expect({{> locator}}).toContainText(__expected);
|
|
7
|
+
}
|
|
8
|
+
{{else~}}
|
|
9
|
+
await expect({{> locator}}).toContainText('{{escapeQuotes expectedText}}');{{/if}}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{{!-- `see [X] is expanded | is collapsed` (#911). Reads the signal expand-action.hbs drives — a class
|
|
2
|
+
on a descendant, a declared boolean attribute, else aria-expanded — but with no "only if the
|
|
3
|
+
element exposes it" escape: an assertion that can skip itself asserts nothing. --}}
|
|
4
|
+
{{#if expanded.class}}
|
|
5
|
+
{{#if (eq direction 'expand')}}
|
|
6
|
+
await expect({{> locator}}.locator('.{{escapeQuotes expanded.class}}')).toBeVisible();
|
|
7
|
+
{{~else~}}
|
|
8
|
+
await expect({{> locator}}.locator('.{{escapeQuotes expanded.class}}')).toHaveCount(0);
|
|
9
|
+
{{~/if}}
|
|
10
|
+
{{~else~}}
|
|
11
|
+
await expect({{> locator}}).toHaveAttribute('{{#if expanded.attribute}}{{escapeQuotes expanded.attribute}}{{else}}aria-expanded{{/if}}', '{{#if (eq direction 'expand')}}true{{else}}false{{/if}}');
|
|
12
|
+
{{~/if}}
|
|
@@ -1187,9 +1187,9 @@ export class CodeGenerator {
|
|
|
1187
1187
|
): { preconditions: CapabilityPrecondition[]; casesDataset?: string } {
|
|
1188
1188
|
this.stepMapper.setScenarioContext(stepsToMap);
|
|
1189
1189
|
|
|
1190
|
-
// Data-driven (@cases): the scenario's {{col}} refs are dataset *row columns*
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1190
|
+
// Data-driven (@cases): the scenario's {{col}} refs that are dataset *row columns* exist
|
|
1191
|
+
// only at runtime — they resolve to a runtime get() (→ rowData.get) instead of a compile-time
|
|
1192
|
+
// YAML lookup. A plain test-data key stays readable at compile time (#950).
|
|
1193
1193
|
const casesTag = scenario.tags.find((t) => t.startsWith('@cases:'));
|
|
1194
1194
|
const casesDataset = casesTag ? casesTag.slice('@cases:'.length).trim() : undefined;
|
|
1195
1195
|
if (casesDataset) {
|
|
@@ -1197,7 +1197,7 @@ export class CodeGenerator {
|
|
|
1197
1197
|
for (const st of stepsToMap) {
|
|
1198
1198
|
for (const m of (st.text || '').matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) refs.add(m[1]);
|
|
1199
1199
|
}
|
|
1200
|
-
|
|
1200
|
+
this.stepMapper.bindCasesDataset(casesDataset, refs);
|
|
1201
1201
|
}
|
|
1202
1202
|
|
|
1203
1203
|
const preconditions = this.capabilityPreconditions(scenario);
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
* nothing for the query half to assert, so the step checks the PATH only. Not an
|
|
50
50
|
* error (the value may legitimately vary per environment), but the step asserts
|
|
51
51
|
* strictly less than it appears to
|
|
52
|
-
* SG-W015 — `see [X] card/item with {{n}}` where the value is a bare number:
|
|
52
|
+
* SG-W015 — `see [X] card/item with {{n}}` where the value is a bare number (in @cases: on
|
|
53
|
+
* any row), or `see [X] row with {{n}}` whose @cases rows mix numbers and text: this compiles
|
|
53
54
|
* to a TEXT assertion (toHaveText('3')), NOT a count — an author reaching for
|
|
54
55
|
* "3 cards are shown" gets an assertion that can only pass if the card's text
|
|
55
56
|
* IS the digit. Count semantics exist on row/table ("see [T] table with {{n}}",
|
|
@@ -67,13 +68,18 @@
|
|
|
67
68
|
* SG-E023 — the step carries an assertion clause (`matches "…"`, `contains`, `has text`, a state)
|
|
68
69
|
* but the generated code is a BARE VISIBILITY check: the clause was swallowed by the
|
|
69
70
|
* catch-all and the test would pass whatever the element says. Four separate defects
|
|
70
|
-
* have arrived this way, so the step compiles to a runtime throw instead.
|
|
71
|
+
* have arrived this way, so the step compiles to a runtime throw instead. Also a
|
|
72
|
+
* trailing `is <word>` / `is not <word>` that is no state (`is on`, `is open`,
|
|
73
|
+
* `is not visible`), other than visibility itself (#911).
|
|
71
74
|
* SG-W017 — a step needs a selector-entry field the entry does not declare, so there is nothing
|
|
72
75
|
* honest to compile. Today: a table paginate step whose table declares no
|
|
73
|
-
* `pagination.next`/`.prev` (#712)
|
|
76
|
+
* `pagination.next`/`.prev` (#712); `is expanded`/`is collapsed` on an entry declaring
|
|
77
|
+
* `expanded.state: none` (#911). The mirror of SG-W004 — that one reports a field
|
|
74
78
|
* written that nothing reads, this one a field a consumer needs that nobody wrote.
|
|
75
79
|
* Compiles the step to a loud runtime throw rather than guessing a locator, which is
|
|
76
80
|
* the #690 failure (a locator no page has, failing far from the cause).
|
|
81
|
+
* Also a web `fill [X] date-picker` whose entry locates a `<button>` (a calendar
|
|
82
|
+
* trigger) and names no `fill` recipe — `.fill()` cannot type into a button (#907).
|
|
77
83
|
* SG-W018 — a selectors.yaml entry declares an `nth:` the compiler cannot use (#720). `nth:` was
|
|
78
84
|
* the one entry field whose VALUE nothing checked: the key is known (SG-W004 quiet) and
|
|
79
85
|
* the `type:` is valid (SG-E027 quiet), so `nth: -1`, `nth: 'last'`, `nth: 1.5` and
|
|
@@ -13,6 +13,9 @@ import { isKnownSelectorType } from './utils/selector-type-validation';
|
|
|
13
13
|
import { loadAppProfile, ResolvedAppProfile } from './app-profile';
|
|
14
14
|
import { capabilityRegistry } from '../../capabilities/registry';
|
|
15
15
|
|
|
16
|
+
/** Trailing `is <word>` that a visibility check honestly asserts (SG-E023 lets these through). */
|
|
17
|
+
const VISIBILITY_WORDS = new Set(['visible', 'displayed', 'shown', 'present']);
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
20
|
* The correction SG-W001 owes a project that gated a platform driver away.
|
|
18
21
|
*
|
|
@@ -143,6 +146,10 @@ export class StepMapper {
|
|
|
143
146
|
this.featurePlatform = featurePlatform;
|
|
144
147
|
this.featurePath = featurePath;
|
|
145
148
|
this.selectorResolver.setFeatureContext(featureName);
|
|
149
|
+
// Scenario-scoped data state (captured vars, the bound @cases dataset) of the previous feature's
|
|
150
|
+
// last scenario must not reach this feature's Background, which is mapped before any scenario
|
|
151
|
+
// resets it: a column `{{k}}` there made a plain `{{k}}` here unreadable (#950).
|
|
152
|
+
this.dataResolver.clearCaptured();
|
|
146
153
|
// One StepMapper serves every feature in a run, so the step counter that names the
|
|
147
154
|
// generated temporaries (`__t_<n>`) used to carry over between files: a screen's spec
|
|
148
155
|
// then depended on how many steps the PRECEDING files happened to contain — i.e. on
|
|
@@ -212,6 +219,23 @@ export class StepMapper {
|
|
|
212
219
|
this.dataResolver.registerCaptured(name);
|
|
213
220
|
}
|
|
214
221
|
|
|
222
|
+
/**
|
|
223
|
+
* `@cases:<dataset>` — bind the scenario's dataset and register as runtime-only the refs that are
|
|
224
|
+
* its COLUMNS (each row supplies them). A plain key the base test-data defines stays a normal ref,
|
|
225
|
+
* so the compiler can still read its value (#950: registering every ref turned
|
|
226
|
+
* `row with {{rows_per_page}}` into a text assertion). A ref that is neither stays runtime-only,
|
|
227
|
+
* as before.
|
|
228
|
+
*/
|
|
229
|
+
bindCasesDataset(dataset: string, refs: Iterable<string>): void {
|
|
230
|
+
const columns = new Set(this.dataResolver.setCasesDataset(dataset, this.featureName));
|
|
231
|
+
for (const ref of refs) {
|
|
232
|
+
const isColumn = columns.has(ref.split(/[.[]/)[0]);
|
|
233
|
+
if (isColumn || this.dataResolver.peekBaseValue(ref, this.featureName) === undefined) {
|
|
234
|
+
this.dataResolver.registerCaptured(ref);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
215
239
|
/**
|
|
216
240
|
* Register the head of a variable bound by the feature's `@beforeAll` hook. Feature-scoped: it
|
|
217
241
|
* survives setScenarioContext() so every scenario can read `{{head.*}}` as a runtime get().
|
|
@@ -291,8 +315,11 @@ export class StepMapper {
|
|
|
291
315
|
/\bsees?\s+\[/.test(step.text) && /\bwith\b/.test(step.text)) {
|
|
292
316
|
// peekBaseValue reads the literal from test-data even in runtime-data mode,
|
|
293
317
|
// where resolveData returns an opaque marker (never numeric-looking).
|
|
318
|
+
// Every value the ref takes — a @cases column has one per row (#950).
|
|
294
319
|
let resolvedValue: string | undefined;
|
|
295
|
-
try {
|
|
320
|
+
try {
|
|
321
|
+
resolvedValue = this.dataResolver.peekValues(step.dataRef, this.featureName)?.find((v) => /^\d+$/.test(v.trim()));
|
|
322
|
+
} catch { /* unresolved at compile time → cannot judge */ }
|
|
296
323
|
if (resolvedValue !== undefined && /^\d+$/.test(resolvedValue.trim())) {
|
|
297
324
|
this.diagnostics.add('SG-W015',
|
|
298
325
|
`\`see [${step.selectorRef}] ${step.elementType} with {{${step.dataRef}}}\` compiles to a TEXT assertion toHaveText('${resolvedValue.trim()}'), NOT a count of ${step.elementType}s. For a count use \`see [Ref] row with {{n}}\` / \`see [T] table with {{n}}\` (or a list "should have count"); if '${resolvedValue.trim()}' really is the ${step.elementType}'s text, assert it on a text/label element instead`,
|
|
@@ -655,7 +682,8 @@ export class StepMapper {
|
|
|
655
682
|
if (!/\b(?:urlMatches|expectPage)\(/.test(mapped.code || '')) return; // both URL-assertion shapes (#662)
|
|
656
683
|
// The base value, never resolveData(): in runtime mode that returns the marker, which carries
|
|
657
684
|
// no query by construction — the diagnostic would then fire on every single step.
|
|
658
|
-
|
|
685
|
+
// Any value the ref takes — a @cases column has one per row (#950); the first without a query.
|
|
686
|
+
const value = this.dataResolver.peekValues(step.dataRef, this.featureName)?.find((v) => !/[?#]/.test(v));
|
|
659
687
|
if (value == null || /[?#]/.test(value)) return;
|
|
660
688
|
this.diagnostics.add('SG-W014',
|
|
661
689
|
`{{${step.dataRef}}} = "${value}" carries no query and no fragment, so this page assertion checks the PATH only. Pass a value like "?q=…" (or drop the "with" and let the step assert a bare page).`,
|
|
@@ -856,13 +884,32 @@ export class StepMapper {
|
|
|
856
884
|
[/\b(?:should\s+have|has)\s+text\b/i, 'has text'],
|
|
857
885
|
];
|
|
858
886
|
const hit = CLAUSES.find(([re]) => re.test(step.text));
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
887
|
+
// A trailing `is <word>` that is no state reached a bare visibility check the same way:
|
|
888
|
+
// `see [Start Date] button is expanded` asserted only that the button was shown, and passed with
|
|
889
|
+
// the calendar closed (#911); `is not visible` even asserted the OPPOSITE. Known states are
|
|
890
|
+
// SG-E021's (`step.state` is set). Only what a visibility check honestly asserts passes:
|
|
891
|
+
// `is visible` and its synonyms, and `is not hidden`. Storage's `is not empty` never gets here —
|
|
892
|
+
// this guard sees bare visibility code only.
|
|
893
|
+
let state: string | undefined;
|
|
894
|
+
if (!hit && !step.state) {
|
|
895
|
+
const m = maskRefsForMatching(step.text).replace(/\s+/g, ' ').replace(/ ?\.$/, '').toLowerCase()
|
|
896
|
+
.match(/ (?:is|are)( not| no longer)? ([a-z][\w-]*)$/);
|
|
897
|
+
const honest = m && (m[1] ? m[2] === 'hidden' : VISIBILITY_WORDS.has(m[2]));
|
|
898
|
+
if (m && !honest) state = `is${m[1] ?? ''} ${m[2]}`;
|
|
899
|
+
}
|
|
900
|
+
if (!hit && !state) return null;
|
|
901
|
+
|
|
902
|
+
const reason = state
|
|
903
|
+
? `step "${step.text}" ends in \`${state}\`, which is not a sungen state, but compiled to a bare `
|
|
904
|
+
+ `visibility check — the words were dropped, so the step would pass whenever the element is `
|
|
905
|
+
+ `shown. Use a state the compiler asserts (is visible / is hidden / is disabled / is enabled / `
|
|
906
|
+
+ `is checked / is unchecked / is selected / is focused / is empty / is expanded / is collapsed …, `
|
|
907
|
+
+ `see the sungen-gherkin-syntax States list), or tag the scenario @manual.`
|
|
908
|
+
: `step "${step.text}" carries the clause \`${hit![1]}\` but compiled to a bare `
|
|
909
|
+
+ `visibility check — the clause was dropped, so the assertion would pass whatever the element `
|
|
910
|
+
+ `says. Either the phrasing is not the supported one for that clause (see the `
|
|
911
|
+
+ `sungen-gherkin-syntax reference), or this sungen predates the step: check you are running `
|
|
912
|
+
+ `the project-local compiler (\`npx sungen\`), not an older global one.`;
|
|
866
913
|
this.diagnostics?.add('SG-E023', reason, { feature: this.featureName, step: step.text });
|
|
867
914
|
return {
|
|
868
915
|
code: unsupportedStepCode(reason),
|
|
@@ -54,6 +54,7 @@ export class DataResolver {
|
|
|
54
54
|
/** Clear captured vars — call at the start of each scenario. */
|
|
55
55
|
clearCaptured(): void {
|
|
56
56
|
this.capturedVars.clear();
|
|
57
|
+
this.casesRows = undefined;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
// Vars bound by a `@beforeAll` hook's `@api:`/`@query:` preconditions. The hook runs before every
|
|
@@ -131,6 +132,58 @@ export class DataResolver {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
/** The `@cases:<name>` dataset rows of the scenario being compiled (scenario-scoped). */
|
|
136
|
+
private casesRows?: Array<Record<string, unknown>>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bind the scenario's `@cases` dataset so its values can be judged at compile time, and return its
|
|
140
|
+
* column names (empty when the dataset cannot be read — the caller then treats every ref as
|
|
141
|
+
* runtime-only, as before #950).
|
|
142
|
+
*/
|
|
143
|
+
setCasesDataset(dataset: string | undefined, featureName?: string): string[] {
|
|
144
|
+
this.casesRows = undefined;
|
|
145
|
+
if (!dataset || !featureName) return [];
|
|
146
|
+
try {
|
|
147
|
+
const rows = this.loadDataFile(featureName, dataset)?.[dataset];
|
|
148
|
+
if (Array.isArray(rows) && rows.length) {
|
|
149
|
+
// A scalar row is the `value` column — the runtime cases() wraps it the same way.
|
|
150
|
+
this.casesRows = rows.map((r) => (r && typeof r === 'object' && !Array.isArray(r) ? r : { value: r }));
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// unreadable dataset → no columns known
|
|
154
|
+
}
|
|
155
|
+
return this.casesRows ? [...new Set(this.casesRows.flatMap((r) => Object.keys(r)))] : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Every value a ref can take at run time, for compile-time judgements: the base value of a plain
|
|
160
|
+
* key, or — for a column of the scenario's `@cases` dataset — the value on EACH row (a row that
|
|
161
|
+
* lacks the column falls back to the base key, as `rowData.get()` does). A dataset column used to
|
|
162
|
+
* be opaque here, so `row with {{n}}` lost its count reading in @cases and the SG-W014 / SG-W015
|
|
163
|
+
* checks went silent (#950). Undefined when any value cannot be read.
|
|
164
|
+
*/
|
|
165
|
+
peekValues(dataRef: string, featureName?: string): string[] | undefined {
|
|
166
|
+
const head = dataRef.split(/[.[]/)[0];
|
|
167
|
+
if (!this.casesRows?.some((row) => head in row)) {
|
|
168
|
+
const base = this.peekBaseValue(dataRef, featureName);
|
|
169
|
+
return base === undefined ? undefined : [base];
|
|
170
|
+
}
|
|
171
|
+
const out: string[] = [];
|
|
172
|
+
for (const row of this.casesRows) {
|
|
173
|
+
let value: unknown;
|
|
174
|
+
if (head in row) {
|
|
175
|
+
value = row;
|
|
176
|
+
for (const key of dataRef.split('.')) value = value && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined;
|
|
177
|
+
} else {
|
|
178
|
+
try { value = this.resolveValue(dataRef, featureName); } catch { return undefined; }
|
|
179
|
+
}
|
|
180
|
+
if (typeof value === 'number' || typeof value === 'boolean') value = String(value);
|
|
181
|
+
if (typeof value !== 'string') return undefined;
|
|
182
|
+
out.push(value);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
134
187
|
private resolveValue(dataRef: string, featureName?: string): string {
|
|
135
188
|
const { fileName, valuePath } = this.parseDataRef(dataRef, featureName);
|
|
136
189
|
const data = this.loadDataFile(fileName, dataRef);
|
|
@@ -1024,8 +1024,8 @@ export class SelectorResolver {
|
|
|
1024
1024
|
// `type: locator` had to be used instead, which no page of the reference says (issue #690).
|
|
1025
1025
|
//
|
|
1026
1026
|
// Handled HERE, above the generic `locator:`-field branch below, because that branch returns
|
|
1027
|
-
// strategy 'css'
|
|
1028
|
-
//
|
|
1027
|
+
// strategy 'css' — putting `upload` in the switch left the two spellings diverging for any
|
|
1028
|
+
// entry that sets `locator:`. `withExtras` still stamps the declared
|
|
1029
1029
|
// `selectorType`, so a consumer that needs to tell an upload from a plain locator still can.
|
|
1030
1030
|
//
|
|
1031
1031
|
// `exact` is inert for the upload action itself (`setInputFiles` takes no text filter), but it
|
|
@@ -1049,6 +1049,7 @@ export class SelectorResolver {
|
|
|
1049
1049
|
value: locator,
|
|
1050
1050
|
locator: locator,
|
|
1051
1051
|
nth,
|
|
1052
|
+
exact,
|
|
1052
1053
|
});
|
|
1053
1054
|
}
|
|
1054
1055
|
|
|
@@ -1113,11 +1114,18 @@ export class SelectorResolver {
|
|
|
1113
1114
|
return resolvedRole;
|
|
1114
1115
|
}
|
|
1115
1116
|
|
|
1117
|
+
// `exact` carries no meaning for getByTestId itself, but text assertions on the entry read it
|
|
1118
|
+
// (`label with {{v}}` → toHaveText vs toContainText). Dropping it here made `exact: true`
|
|
1119
|
+
// inert on a testid entry while working on every other identity strategy (#910).
|
|
1116
1120
|
case 'testid':
|
|
1121
|
+
// `exact` travels with a testid entry too: the getByTestId locator ignores it, but a step
|
|
1122
|
+
// that compares text (`label with {{v}}`) reads it to pick toHaveText over toContainText.
|
|
1123
|
+
// It used to be dropped here alone, so `exact: true` on a testid entry did nothing (#910).
|
|
1117
1124
|
return withExtras({
|
|
1118
1125
|
strategy: 'testid',
|
|
1119
1126
|
value,
|
|
1120
1127
|
nth,
|
|
1128
|
+
exact,
|
|
1121
1129
|
});
|
|
1122
1130
|
|
|
1123
1131
|
case 'label':
|
package/src/harness/audit.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { discoverAndRegisterCapabilities } from '../capabilities/discover';
|
|
|
34
34
|
import { contextRouter } from '../capabilities/context-router';
|
|
35
35
|
import { readTextFile, readViewpointText } from './read-text';
|
|
36
36
|
import { uiInventoryCoverage, uiInventoryFindings, UiInventoryResult } from './ui-inventory';
|
|
37
|
+
import { defectLinkFindings } from './defects';
|
|
37
38
|
|
|
38
39
|
export interface AuditReport {
|
|
39
40
|
screen: string;
|
|
@@ -215,9 +216,13 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
215
216
|
? { ...catalog, page_types: {} } as Catalog
|
|
216
217
|
: catalog;
|
|
217
218
|
const capGate = scoringCap?.gateProvider as
|
|
218
|
-
((i: { scenarios: ScenarioInfo[]; viewpoints: ViewpointEntry[]; catalog: Catalog; focus: typeof intent.focus; cwd: string; screenName: string; threshold: number; businessCriticalMethods?: string[] }) => { gate: GateResult; depth: DepthResult }) | undefined;
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
((i: { scenarios: ScenarioInfo[]; viewpoints: ViewpointEntry[]; catalog: Catalog; focus: typeof intent.focus; cwd: string; screenName: string; threshold: number; businessCriticalMethods?: string[]; isMobile: boolean; viewpointText: string }) => { gate: GateResult; depth: DepthResult }) | undefined;
|
|
220
|
+
// The capability gate gets the SAME isMobile + viewpoint text as the in-core fallback, or a declared
|
|
221
|
+
// `page-type:` is ignored wherever driver-ui scores — every web project, and a mobile project whose
|
|
222
|
+
// CLI cannot resolve driver-mobile (#864).
|
|
223
|
+
const isMobile = platform === 'mobile';
|
|
224
|
+
const provided = capGate?.({ scenarios, viewpoints, catalog: gateCatalog, focus: intent.focus, cwd: projectRootFromScreenDir(screenDir), screenName: catalogScreenName, threshold: depthThresholdFor(intent.focus), businessCriticalMethods: intent.businessCriticalMethods, isMobile, viewpointText });
|
|
225
|
+
const gate = provided?.gate ?? viewpointGate(scenarios, viewpoints, gateCatalog, isMobile, viewpointText);
|
|
221
226
|
const depth = provided?.depth ?? assertionDepth(scenarios, dataThemesFor(gateCatalog, gate.pageType), intent.focus);
|
|
222
227
|
|
|
223
228
|
// TQ-4 — deferral-aware coverage credit: an inherently cross-screen theme (cart / detail /
|
|
@@ -618,9 +623,15 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
618
623
|
// #414 — serial-cascade masking: an @serial suite of independent (re-navigating) scenarios hides
|
|
619
624
|
// failures, because Playwright's describe.serial SKIPS everything after the first red. Isolation is
|
|
620
625
|
// the default, so this only fires when @serial was added to a suite that didn't need it.
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
626
|
+
// `@serial` is a property of ONE feature file — only that file compiles to `describe.serial` — so
|
|
627
|
+
// each file is judged on its own text and its own scenarios. The unit-wide text paired one
|
|
628
|
+
// feature's @serial with another's navigating Background and counted every scenario of the unit,
|
|
629
|
+
// telling QA to drop @serial from a genuinely ordered 2-scenario feature (#887).
|
|
630
|
+
for (const file of featureFiles.filter((f) => fs.existsSync(f))) {
|
|
631
|
+
const cascadeN = serialCascadeRisk(readTextFile(file), scenarios.filter((s) => s.sourceFile === file).length);
|
|
632
|
+
if (cascadeN) {
|
|
633
|
+
findings.push(`SERIAL-CASCADE-RISK: the \`@serial\` suite ${path.basename(file)} has ${cascadeN} INDEPENDENT scenarios (each re-navigates via Background) that don't need a shared-page sequence. \`describe.serial\` SKIPS every test after the first failure → one early red MASKS the rest as never-run. **Drop \`@serial\`** — the default per-scenario isolation runs every scenario and isolates failures. Keep \`@serial\` only for a genuinely stateful ordered sequence (where scenario N depends on N-1's state).`);
|
|
634
|
+
}
|
|
624
635
|
}
|
|
625
636
|
if (flowDepth.stateful && flowDepth.missing.length) {
|
|
626
637
|
// The advice names the collection the CONTRACT declared. It used to say "the cart" whatever
|
|
@@ -647,6 +658,8 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
647
658
|
for (const m of taxonomy.mislabeled) {
|
|
648
659
|
findings.push(`VP-MISLABEL: "${m.name}" is coded VP-${m.current} but reads as ${m.suggested} (signal: "${m.signal}") → re-tag VP-${m.suggested}-NNN so the coverage matrix isn't skewed.`);
|
|
649
660
|
}
|
|
661
|
+
// Scenario ↔ defect record links (qa/defects/) — advisory, never scored.
|
|
662
|
+
findings.push(...defectLinkFindings(projectRootFromScreenDir(screenDir), catalogScreenName, featureText, scenarios.map((sc) => sc.name)));
|
|
650
663
|
if (balance.unclassifiedRatio > 0.4) {
|
|
651
664
|
findings.push(`TAXONOMY-UNCLASSIFIED: ${balance.note} → align the VP-<CATEGORY> codes with the catalog (or extend the bucket keywords) so coverage-balance is meaningful.`);
|
|
652
665
|
} else if (balance.imbalanced && !flowScored) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many items a scenario's assertions demonstrate — the denominator of CLAIM-PARTIAL (#622).
|
|
3
|
+
*
|
|
4
|
+
* One reading for both places that check a counted claim (the viewpoint ledger and the title-level
|
|
5
|
+
* `numericClaimGaps`), which used to carry their own copy of the regex.
|
|
6
|
+
*
|
|
7
|
+
* Counted, once per distinct assertion:
|
|
8
|
+
* - a value: `see [X] … with|contains {{v}}`;
|
|
9
|
+
* - a positive state: `see [X] … is checked|selected|enabled|disabled`;
|
|
10
|
+
* - an indicator's presence: `see [X] icon|checkbox|toggle|radio|badge|image` (#866) — on mobile a
|
|
11
|
+
* ticked item is often observable ONLY as its tick icon, so one per item is the proof.
|
|
12
|
+
* Not counted: plain visibility of text/headers (it shows a label, not one of N values) and absence
|
|
13
|
+
* (`is hidden`, `not …`), which proves nothing is there.
|
|
14
|
+
*/
|
|
15
|
+
// The tail stops before the NEXT `see [`, or consecutive assertions would swallow each other.
|
|
16
|
+
const SEE_STEP = /\bsee \[([^\]]+)\]((?:(?!\bsee \[)[^[])*)/g;
|
|
17
|
+
const VALUE = /^[^|]*?\b(?:with|contains) (\{\{[^}]*\}\})/;
|
|
18
|
+
const ABSENT = /^\s*\S*\s*(?:is hidden|is not\b|not\b|does not\b)/;
|
|
19
|
+
const STATE = /^\s*\S*\s*is (checked|selected|enabled|disabled)\b/;
|
|
20
|
+
const INDICATOR = /^\s*(icon|checkbox|toggle|radio|badge|image)\b/;
|
|
21
|
+
|
|
22
|
+
export function countedAssertions(stepsText: string): number {
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
for (const m of stepsText.matchAll(SEE_STEP)) {
|
|
25
|
+
const [, target, rest] = m;
|
|
26
|
+
if (ABSENT.test(rest)) continue;
|
|
27
|
+
// Keyed on the target and WHAT it proves — never on the tail, which runs into the next step, so
|
|
28
|
+
// the same element asserted before and after an action is still one assertion.
|
|
29
|
+
const proof = VALUE.exec(rest)?.[1] ?? STATE.exec(rest)?.[1] ?? INDICATOR.exec(rest)?.[1];
|
|
30
|
+
if (proof) seen.add(`${target}|${proof}`);
|
|
31
|
+
}
|
|
32
|
+
return seen.size;
|
|
33
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sungen defect draft` — turn each `product-candidate` row of a unit's last triage into a record
|
|
3
|
+
* `qa/defects/DEF-NNN-<slug>.md` (see defects.ts for the link checks). Deterministic: Steps come
|
|
4
|
+
* from the scenario's Gherkin with test-data resolved, Expected/Actual from the assertion, and the
|
|
5
|
+
* severity is PROPOSED by rule until QA confirms it. Never overwrites a file, and never drafts a
|
|
6
|
+
* scenario an existing record already names.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { stringify as toYaml } from 'yaml';
|
|
11
|
+
import type { ResolvedUnit } from '../cli/resolve-unit';
|
|
12
|
+
import { redactSecrets } from '../exporters/api-testcase-formatter';
|
|
13
|
+
import { getEnvironment } from '../exporters/run-environment';
|
|
14
|
+
import { parseFeatureMetadata } from '../exporters/feature-parser';
|
|
15
|
+
import { mergeFeatureAndSpec, MergedScenario } from '../exporters/scenario-merger';
|
|
16
|
+
import { maskCredentialValue } from '../exporters/sensitive-key';
|
|
17
|
+
import { parseSpecFile } from '../exporters/spec-parser';
|
|
18
|
+
import { loadTestData, substituteTestDataVars } from '../exporters/test-data-resolver';
|
|
19
|
+
import { readCapabilities } from './capability';
|
|
20
|
+
import { defectsDir, nextDefectId, readDefects } from './defects';
|
|
21
|
+
import type { TriageRow } from './triage';
|
|
22
|
+
import { readTriageReport, unitSecrets } from './triage-unit';
|
|
23
|
+
|
|
24
|
+
export interface DraftResult {
|
|
25
|
+
/** `sungen triage` has not run for this unit. */
|
|
26
|
+
missingTriage?: boolean;
|
|
27
|
+
written: string[];
|
|
28
|
+
skipped: Array<{ scenario: string; id: string }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Blocking actions: a @high scenario that fails on one of these is `major`, otherwise `minor`. */
|
|
32
|
+
const BLOCKING = /\b(?:submit|save|apply|log ?in|sign ?in|sign ?up|register|checkout|pay|confirm|send|navigate|redirect)\b/i;
|
|
33
|
+
|
|
34
|
+
const slug = (s: string): string =>
|
|
35
|
+
s.replace(/^\s*(?:VP|FL)-[A-Z0-9-]+\s+/i, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 50).replace(/-$/, '');
|
|
36
|
+
|
|
37
|
+
interface UnitScenario { merged: MergedScenario; testData: Record<string, string>; secrets: string[] }
|
|
38
|
+
|
|
39
|
+
/** Scenario title → its merged Gherkin, test-data (credentials masked) and secret values. */
|
|
40
|
+
function unitScenarios(cwd: string, unit: ResolvedUnit): Map<string, UnitScenario> {
|
|
41
|
+
const out = new Map<string, UnitScenario>();
|
|
42
|
+
const featuresDir = path.join(unit.dir, 'features');
|
|
43
|
+
if (!fs.existsSync(featuresDir)) return out;
|
|
44
|
+
for (const f of fs.readdirSync(featuresDir).filter((x) => x.endsWith('.feature'))) {
|
|
45
|
+
const base = f.replace(/\.feature$/, '');
|
|
46
|
+
const spec = parseSpecFile(path.join(cwd, 'specs', 'generated', unit.unitId, `${base}.spec.ts`));
|
|
47
|
+
const dataFile = path.join(unit.dir, 'test-data', `${base}.yaml`);
|
|
48
|
+
const raw = fs.existsSync(dataFile) ? loadTestData(dataFile) : {};
|
|
49
|
+
const testData = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, maskCredentialValue(k, String(v))]));
|
|
50
|
+
const secrets = unitSecrets(raw, cwd);
|
|
51
|
+
for (const m of mergeFeatureAndSpec(parseFeatureMetadata(path.join(featuresDir, f)), spec)) {
|
|
52
|
+
out.set(m.feature.name, { merged: m, testData, secrets });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function render(cwd: string, unit: ResolvedUnit, id: string, row: TriageRow, sc: UnitScenario | undefined): string {
|
|
59
|
+
const tags = sc?.merged.feature.tags ?? [];
|
|
60
|
+
const resolve = (t: string): string => (sc ? substituteTestDataVars(t, sc.testData) : t);
|
|
61
|
+
const steps = (sc?.merged.resolvedSteps ?? []).map((s) => resolve(s.text));
|
|
62
|
+
const thens = (sc?.merged.resolvedExpected ?? []).map((s) => resolve(s.text));
|
|
63
|
+
const high = tags.some((t) => t === '@high' || t === '@critical');
|
|
64
|
+
const severity = high && BLOCKING.test([row.scenario, ...steps].join(' ')) ? 'major' : 'minor';
|
|
65
|
+
const specFile = path.join(unit.dir, 'requirements', 'spec.md');
|
|
66
|
+
const env = getEnvironment(cwd);
|
|
67
|
+
const front = {
|
|
68
|
+
id, title: row.scenario, severity, severity_source: 'proposed', status: 'open',
|
|
69
|
+
units: [unit.unitId], scenarios: [row.scenario], claims: row.ids, legacy: row.legacy,
|
|
70
|
+
spec: fs.existsSync(specFile) ? path.relative(cwd, specFile) : '',
|
|
71
|
+
found: {
|
|
72
|
+
date: new Date().toISOString().slice(0, 10),
|
|
73
|
+
env: process.env.SUNGEN_ENV || 'default',
|
|
74
|
+
base_url: env.baseURL,
|
|
75
|
+
platform: row.variant || readCapabilities(cwd).platform || 'web',
|
|
76
|
+
},
|
|
77
|
+
tracker: '',
|
|
78
|
+
};
|
|
79
|
+
const body = [
|
|
80
|
+
'---', toYaml(front).trimEnd(), '---', '',
|
|
81
|
+
`# ${id}: ${row.scenario}`, '',
|
|
82
|
+
'## Summary', '', '(to fill: what is wrong, where, and who it affects)', '',
|
|
83
|
+
'## Steps to reproduce', '', ...(steps.length ? steps.map((s, i) => `${i + 1}. ${s}`) : ['(scenario steps not found — see the feature)']), '',
|
|
84
|
+
'## Expected', '', ...thens.map((t) => `- ${t}`), ...(row.expected ? [`- Asserted value: ${row.expected}`] : []), '',
|
|
85
|
+
'## Actual', '', `- ${row.received ? `Received: ${row.received}` : row.evidence}`, '',
|
|
86
|
+
'## Evidence', '', `- Error: ${row.evidence}`, ...(row.tracePath ? [`- Trace: ${row.tracePath}`] : []), '',
|
|
87
|
+
'## Notes', '', `- Triage: ${row.class} (${row.ruleId})${row.variant ? ` on ${row.variant}` : ''}`, '',
|
|
88
|
+
].join('\n');
|
|
89
|
+
return redactSecrets(body, sc?.secrets ?? []);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function draftDefects(cwd: string, unit: ResolvedUnit, opts: { scenario?: string } = {}): DraftResult {
|
|
93
|
+
const triage = readTriageReport(cwd, unit.unitId);
|
|
94
|
+
if (!triage) return { missingTriage: true, written: [], skipped: [] };
|
|
95
|
+
const recorded = new Map<string, string>();
|
|
96
|
+
for (const r of readDefects(cwd)) if (r.units.includes(unit.unitId)) for (const s of r.scenarios) recorded.set(s, r.id);
|
|
97
|
+
|
|
98
|
+
const wanted = triage.rows.filter((r) => r.class === 'product-candidate'
|
|
99
|
+
&& (!opts.scenario || r.scenario.includes(opts.scenario) || r.ids.includes(opts.scenario)));
|
|
100
|
+
const scenarios = unitScenarios(cwd, unit);
|
|
101
|
+
const result: DraftResult = { written: [], skipped: [] };
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
for (const row of wanted) {
|
|
104
|
+
if (seen.has(row.scenario)) continue; // one record per scenario, even across OS variants
|
|
105
|
+
seen.add(row.scenario);
|
|
106
|
+
const existing = recorded.get(row.scenario);
|
|
107
|
+
if (existing) { result.skipped.push({ scenario: row.scenario, id: existing }); continue; }
|
|
108
|
+
const id = nextDefectId(cwd);
|
|
109
|
+
const file = path.join(defectsDir(cwd), `${id}-${slug(row.scenario) || 'defect'}.md`);
|
|
110
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
111
|
+
fs.writeFileSync(file, render(cwd, unit, id, row, scenarios.get(row.scenario)), { encoding: 'utf-8', flag: 'wx' });
|
|
112
|
+
result.written.push(path.relative(cwd, file));
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|