@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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defect records — `qa/defects/DEF-NNN-<slug>.md`, one project-wide folder. A scenario points at a
|
|
3
|
+
* record with `@known-defect:DEF-NNN`; the record points back at units, scenarios, claims and legacy
|
|
4
|
+
* TCs in its frontmatter. This module reads the records and checks both directions of that link.
|
|
5
|
+
* Drafting a record from a triage row lives in defect-draft.ts.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { parse as parseYaml } from 'yaml';
|
|
10
|
+
|
|
11
|
+
export const DEFECT_STATUSES = ['open', 'fixed', 'verified', 'closed', 'wont-fix'] as const;
|
|
12
|
+
export type DefectStatus = typeof DEFECT_STATUSES[number];
|
|
13
|
+
|
|
14
|
+
export interface DefectRecord {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
severity: string;
|
|
18
|
+
status: string;
|
|
19
|
+
units: string[];
|
|
20
|
+
scenarios: string[];
|
|
21
|
+
/** Project-relative path of the record file. */
|
|
22
|
+
file: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEF_FILE = /^(DEF-(\d+))-.*\.md$/;
|
|
26
|
+
const KNOWN_DEFECT_TAG = /@known-defect:(DEF-\d+)\b/g;
|
|
27
|
+
|
|
28
|
+
export function defectsDir(cwd: string): string {
|
|
29
|
+
return path.join(cwd, 'qa', 'defects');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defectFiles(cwd: string): string[] {
|
|
33
|
+
const dir = defectsDir(cwd);
|
|
34
|
+
return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => DEF_FILE.test(f)).sort() : [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The next free id: one above the highest number on disk, zero-padded to 3. */
|
|
38
|
+
export function nextDefectId(cwd: string): string {
|
|
39
|
+
const max = defectFiles(cwd).reduce((m, f) => Math.max(m, Number(f.match(DEF_FILE)![2])), 0);
|
|
40
|
+
return `DEF-${String(max + 1).padStart(3, '0')}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const list = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []);
|
|
44
|
+
|
|
45
|
+
/** Every record whose frontmatter parses; a malformed file is skipped, never thrown on. */
|
|
46
|
+
export function readDefects(cwd: string): DefectRecord[] {
|
|
47
|
+
const out: DefectRecord[] = [];
|
|
48
|
+
for (const f of defectFiles(cwd)) {
|
|
49
|
+
const file = path.join(defectsDir(cwd), f);
|
|
50
|
+
try {
|
|
51
|
+
const fm = parseYaml(fs.readFileSync(file, 'utf-8').split(/^---\s*$/m)[1] ?? '') as Record<string, unknown> | null;
|
|
52
|
+
if (!fm || typeof fm !== 'object') continue;
|
|
53
|
+
out.push({
|
|
54
|
+
id: String(fm.id ?? f.match(DEF_FILE)![1]),
|
|
55
|
+
title: String(fm.title ?? ''),
|
|
56
|
+
severity: String(fm.severity ?? ''),
|
|
57
|
+
status: String(fm.status ?? ''),
|
|
58
|
+
units: list(fm.units),
|
|
59
|
+
scenarios: list(fm.scenarios),
|
|
60
|
+
file: path.relative(cwd, file),
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
// unreadable frontmatter: not a record we can reason about
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Advisory audit findings for one unit (never scored):
|
|
71
|
+
* DEFECT-REF-MISSING a `@known-defect:DEF-NNN` tag with no record file.
|
|
72
|
+
* DEFECT-ORPHAN (info) an OPEN record of this unit names a scenario the unit no longer has.
|
|
73
|
+
*/
|
|
74
|
+
export function defectLinkFindings(cwd: string, unitId: string, featureText: string, scenarioNames: string[]): string[] {
|
|
75
|
+
const records = readDefects(cwd);
|
|
76
|
+
const findings: string[] = [];
|
|
77
|
+
const known = new Set(records.map((r) => r.id));
|
|
78
|
+
const tagged = new Set([...featureText.matchAll(KNOWN_DEFECT_TAG)].map((m) => m[1]));
|
|
79
|
+
for (const id of tagged) {
|
|
80
|
+
if (!known.has(id)) {
|
|
81
|
+
findings.push(`DEFECT-REF-MISSING: \`@known-defect:${id}\` has no record in qa/defects/ — draft it with \`sungen defect draft --screen ${unitId}\`, or correct the id.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const names = new Set(scenarioNames);
|
|
85
|
+
for (const r of records) {
|
|
86
|
+
if (r.status !== 'open' || !r.units.includes(unitId)) continue;
|
|
87
|
+
for (const s of r.scenarios) {
|
|
88
|
+
if (!names.has(s)) {
|
|
89
|
+
findings.push(`DEFECT-ORPHAN (info): ${r.id} is open and names scenario "${s}", which ${unitId} no longer has — update the record's scenarios, or close it.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return findings;
|
|
94
|
+
}
|
package/src/harness/manifest.ts
CHANGED
|
@@ -13,6 +13,7 @@ import * as path from 'path';
|
|
|
13
13
|
import { createHash } from 'crypto';
|
|
14
14
|
import { featureFilesFor, reportSlug } from './unit-paths';
|
|
15
15
|
import { readTextFile } from './read-text';
|
|
16
|
+
import { vpCodeOf } from './parse';
|
|
16
17
|
|
|
17
18
|
export interface SpecSection { name: string; hash: string }
|
|
18
19
|
export interface ManifestEntry { scenario: string; vpCode?: string; section: string; specHash: string }
|
|
@@ -90,8 +91,7 @@ function parseFeatureSections(featurePath: string): { scenario: string; vpCode?:
|
|
|
90
91
|
const s = line.match(/^Scenario:\s*(.*)$/);
|
|
91
92
|
if (s) {
|
|
92
93
|
const name = s[1].trim();
|
|
93
|
-
|
|
94
|
-
out.push({ scenario: name, vpCode: code, section });
|
|
94
|
+
out.push({ scenario: name, vpCode: vpCodeOf(name).vpCode, section });
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
return out;
|
package/src/harness/next-step.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import * as fs from 'fs';
|
|
18
18
|
import * as path from 'path';
|
|
19
|
+
import { parse as parseYaml } from 'yaml';
|
|
19
20
|
import { reportSlug, featureFilesFor } from './unit-paths';
|
|
20
21
|
import { readTextFile } from './read-text';
|
|
21
22
|
import { checkAssetsDrift } from '../orchestrator/assets-drift';
|
|
@@ -87,6 +88,29 @@ function countScenarios(unitDir: string, unit: string): number {
|
|
|
87
88
|
* Everything the resolver needs, read from disk. `unitId` is the catalog id
|
|
88
89
|
* (`<screen>` · `flows/<f>` · `api/<a>`); `unitDir` is its directory.
|
|
89
90
|
*/
|
|
91
|
+
const RESULT_FILE = /-test-result(?:\.[^.]+)*\.json$/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Does the selector text resolve at least one element? A scaffold resolves none: the web one holds
|
|
95
|
+
* only the `page` route, the mobile one only header comments. Matching the WORD "placeholder" was
|
|
96
|
+
* wrong both ways (#868): it flagged a real file whose key names an input's placeholder text, and
|
|
97
|
+
* missed both scaffolds, which never contain it.
|
|
98
|
+
*/
|
|
99
|
+
function hasResolvedSelector(selectorText: string): boolean {
|
|
100
|
+
let doc: unknown;
|
|
101
|
+
try { doc = parseYaml(selectorText); } catch { return selectorText.trim().length >= 40; }
|
|
102
|
+
if (!doc || typeof doc !== 'object') return false;
|
|
103
|
+
// A real cell, not a marker left to be filled in. Numbers count (an id or index can be numeric).
|
|
104
|
+
const real = (v: unknown): boolean => (typeof v === 'string' || typeof v === 'number')
|
|
105
|
+
&& String(v).trim() !== '' && !/^(?:todo|tbd|placeholder|replace me)$/i.test(String(v).trim());
|
|
106
|
+
return Object.values(doc as Record<string, unknown>).some((e) => {
|
|
107
|
+
if (!e || typeof e !== 'object') return false;
|
|
108
|
+
const { type, value, name, locator } = e as { type?: unknown; value?: unknown; name?: unknown; locator?: unknown };
|
|
109
|
+
// Every SelectorEntry shape: a CSS `locator`, or a non-page `type` with a value or a role name.
|
|
110
|
+
return real(locator) || (typeof type === 'string' && type !== 'page' && (real(value) || real(name)));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
export function readUnitState(
|
|
91
115
|
projectRoot: string, unitDir: string, unit: string, kind: UnitState['kind'],
|
|
92
116
|
): UnitState {
|
|
@@ -101,7 +125,7 @@ export function readUnitState(
|
|
|
101
125
|
// `readTextFile` throws on a missing file, and every path here is legitimately absent at some
|
|
102
126
|
// point in a unit's life — the resolver exists to report exactly that, so it must not crash on it.
|
|
103
127
|
const read = (p: string): string => (fs.existsSync(p) ? readTextFile(p) : '');
|
|
104
|
-
const
|
|
128
|
+
const selectorTexts = selectorFiles.map((f) => read(path.join(selectorsDir, f)));
|
|
105
129
|
const contractPath = path.join(req, 'flow-contract.yaml');
|
|
106
130
|
const contractText = read(contractPath);
|
|
107
131
|
|
|
@@ -116,7 +140,9 @@ export function readUnitState(
|
|
|
116
140
|
});
|
|
117
141
|
const hasResults = specDirs.some((d) => {
|
|
118
142
|
try {
|
|
119
|
-
|
|
143
|
+
// `<f>-test-result[.<os>][.<env>].json` — a mobile run writes the OS into the name, an env
|
|
144
|
+
// run (SUNGEN_ENV=staging) the env; both are runs (#868).
|
|
145
|
+
return fs.readdirSync(d).some((f) => RESULT_FILE.test(f));
|
|
120
146
|
} catch { return false; }
|
|
121
147
|
});
|
|
122
148
|
let hasDeliverable = false;
|
|
@@ -139,8 +165,7 @@ export function readUnitState(
|
|
|
139
165
|
hasSelectors: selectorFiles.length > 0,
|
|
140
166
|
// A scaffolded selector file exists but resolves nothing; treating it as "done" is what
|
|
141
167
|
// makes a run look further along than it is.
|
|
142
|
-
selectorsArePlaceholder: selectorFiles.length > 0
|
|
143
|
-
&& (/TODO|PLACEHOLDER|REPLACE ME/i.test(selectorText) || selectorText.trim().length < 40),
|
|
168
|
+
selectorsArePlaceholder: selectorFiles.length > 0 && !selectorTexts.some(hasResolvedSelector),
|
|
144
169
|
compiled, hasResults, hasDeliverable,
|
|
145
170
|
assetsDrift: (() => {
|
|
146
171
|
const d = checkAssetsDrift(projectRoot);
|
package/src/harness/parse.ts
CHANGED
|
@@ -285,6 +285,25 @@ function withBackground(sc: ParsedScenario, background?: ParsedScenario): Parsed
|
|
|
285
285
|
return { ...sc, steps: [...bg, ...((sc.steps as ParsedStep[]) || [])] } as ParsedScenario;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The `VP-<CATEGORY>-<n>` / `FL-<PHASE>-<n>` code in a scenario title, and its category — the ONE
|
|
290
|
+
* reading of it; the manifest kept an older copy of this regex and recorded no code for
|
|
291
|
+
* VP-I18N-001, VP-LIST-DISPLAY-01, VP-AF02-01 or any FL- id (#889).
|
|
292
|
+
*
|
|
293
|
+
* Category is everything between `VP-` and the final `-<sequence>` — INCLUDING hyphens, so
|
|
294
|
+
* compound categories (VP-LIST-DISPLAY-01, VP-ADD-TO-CART-03, VP-PRODUCT-DISCOVERY-02) parse,
|
|
295
|
+
* not just single-word ones. A single-word category (VP-CART-001) still works. (H1)
|
|
296
|
+
* Flows use journey-phase ids (FL-HP-001 / FL-ER-002) — the VP- anchor rejected them, so every
|
|
297
|
+
* flow scenario had NO category and the whole suite bucketed `other` (taxonomy=0%, #569).
|
|
298
|
+
* Segments may carry digits: the use-case framework numbers its flows (VP-AF02-01,
|
|
299
|
+
* VP-EF11-01 — one id per Alternate/Exception Flow), and `[A-Z]+` also silently truncated
|
|
300
|
+
* VP-I18N-001 to no category at all (the `18` broke the class mid-segment).
|
|
301
|
+
*/
|
|
302
|
+
export function vpCodeOf(title: string): { vpCode?: string; category?: string } {
|
|
303
|
+
const m = title.match(/\b(?:VP|FL)-([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-\d+/i);
|
|
304
|
+
return m ? { vpCode: m[0].toUpperCase(), category: m[1].toUpperCase() } : {};
|
|
305
|
+
}
|
|
306
|
+
|
|
288
307
|
function classifyScenario(sc: ParsedScenario): ScenarioInfo {
|
|
289
308
|
const tags = sc.tags || [];
|
|
290
309
|
const deferredToFlow = tags.includes('@deferred:flow');
|
|
@@ -310,17 +329,7 @@ function classifyScenario(sc: ParsedScenario): ScenarioInfo {
|
|
|
310
329
|
let priority: Priority = 'unknown';
|
|
311
330
|
for (const t of tags) if (PRIORITY_TAGS[t]) priority = PRIORITY_TAGS[t];
|
|
312
331
|
|
|
313
|
-
|
|
314
|
-
// compound categories (VP-LIST-DISPLAY-01, VP-ADD-TO-CART-03, VP-PRODUCT-DISCOVERY-02) parse,
|
|
315
|
-
// not just single-word ones. A single-word category (VP-CART-001) still works. (H1)
|
|
316
|
-
// Flows use journey-phase ids (FL-HP-001 / FL-ER-002) — the VP- anchor rejected them, so every
|
|
317
|
-
// flow scenario had NO category and the whole suite bucketed `other` (taxonomy=0%, #569).
|
|
318
|
-
// Segments may carry digits: the use-case framework numbers its flows (VP-AF02-01,
|
|
319
|
-
// VP-EF11-01 — one id per Alternate/Exception Flow), and `[A-Z]+` also silently truncated
|
|
320
|
-
// VP-I18N-001 to no category at all (the `18` broke the class mid-segment).
|
|
321
|
-
const codeMatch = sc.name.match(/\b(?:VP|FL)-([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-\d+/i);
|
|
322
|
-
const vpCode = codeMatch ? codeMatch[0].toUpperCase() : undefined;
|
|
323
|
-
const category = codeMatch ? codeMatch[1].toUpperCase() : undefined;
|
|
332
|
+
const { vpCode, category } = vpCodeOf(sc.name);
|
|
324
333
|
// Project-scheme ID: the leading token of the title (VP0-001 / MS-HP-001 / VP-LIST-001).
|
|
325
334
|
const leadMatch = sc.name.match(/^\s*([A-Za-z][A-Za-z0-9.-]*)/);
|
|
326
335
|
const vpId = leadMatch && isIdLike(leadMatch[1]) ? leadMatch[1].toUpperCase() : undefined;
|
|
@@ -107,6 +107,12 @@ const ASSERTION_STEP_COMMENT = /^\s*\/\/\s*Assert\b/;
|
|
|
107
107
|
// but as a bare substring it would also exempt a genuinely hollow step whose test-data or step
|
|
108
108
|
// text happened to contain it.
|
|
109
109
|
const NOOP_STEP_MARKER = /^\s*\/\/\s*App launched via wdio capabilities/;
|
|
110
|
+
// The same kind of step on the web adapter, where the explanation IS the step comment: the
|
|
111
|
+
// step-mapper compiles `Given User is on [Edit] dialog` (and its exit) to `// Enter dialog scope:
|
|
112
|
+
// Edit` and no code, because it only re-roots the steps after it — the next assertion runs as
|
|
113
|
+
// `page.getByRole('dialog', …).getByRole(…)`. A field unit reported 13 BYPASS, every one of them
|
|
114
|
+
// this comment, and exited 2 on a correct suite (#881). Anchored to the whole marker line.
|
|
115
|
+
const SCOPE_STEP_MARKER = /^\s*\/\/\s*(?:Enter|Exit) dialog scope(?::\s*.*)?$/;
|
|
110
116
|
|
|
111
117
|
/** A spec comment line reduced to its text, for comparison against a compiler step comment. */
|
|
112
118
|
const commentText = (line: string): string => line.trim().replace(/^\/\/\s?/, '').trim();
|
|
@@ -241,7 +247,7 @@ export function analyzeFaithfulness(
|
|
|
241
247
|
const hasCode = region.some((l) => !isComment(l) && CODE_LINE.test(l));
|
|
242
248
|
// A documented no-op emits its explanation INTO the step's region, so read the region
|
|
243
249
|
// (not just the marker line) for it — see NOOP_STEP_MARKER.
|
|
244
|
-
if (hasCode || region.some((l) => NOOP_STEP_MARKER.test(l))) continue;
|
|
250
|
+
if (hasCode || region.some((l) => NOOP_STEP_MARKER.test(l)) || SCOPE_STEP_MARKER.test(body[markers[s]])) continue;
|
|
245
251
|
if (tailAmbiguous && s === markers.length - 1) continue;
|
|
246
252
|
hollowSteps.push({ test: title, step: body[markers[s]].trim().slice(0, 70) });
|
|
247
253
|
}
|
package/src/harness/sensors.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
12
12
|
import { ScenarioInfo, ViewpointEntry, idPrefix } from './parse';
|
|
13
13
|
import { readTextFile } from './read-text';
|
|
14
14
|
import { keywordHit, hasKeyword } from './keyword-match';
|
|
15
|
+
import { countedAssertions } from './counted-assertions';
|
|
15
16
|
|
|
16
17
|
// Business-critical category keywords (matched by CONTAINMENT against the VP category, so a
|
|
17
18
|
// compound category like LIST-DISPLAY / ADD-TO-CART / PRODUCT-DISCOVERY classifies correctly).
|
|
@@ -947,7 +948,14 @@ export interface TaxonomyResult {
|
|
|
947
948
|
|
|
948
949
|
const TAXONOMY_KNOWN = new Set(['LIST', 'PRODUCT', 'CART', 'FILTER', 'NAV', 'VAL', 'SEC']);
|
|
949
950
|
|
|
950
|
-
|
|
951
|
+
/**
|
|
952
|
+
* Words of ACCESS — a title about who may reach something. They make the robustness/routing words
|
|
953
|
+
* below security evidence ("deep link without authentication"); alone those words describe routing.
|
|
954
|
+
*/
|
|
955
|
+
const ACCESS_CONTEXT = /\b(xss|sql|injection|without authentication|unauthenticated|unauthori[sz]ed|without an? (?:authenticated|valid|active) \w+|without (?:permission|login|logging in|signing in|an? account|access)|not (?:logged|signed) in|logged[- ]out|direct(?:ly)? access\w*|auth[- ]?guard|forbidden|\b403\b|(?:no|insufficient|missing) permissions?|permission required|session expired|expired session|tamper\w*)\b/;
|
|
956
|
+
|
|
957
|
+
/** `requires`: the signal counts only when the title ALSO matches it (#888). */
|
|
958
|
+
const CATEGORY_SIGNALS: { cat: string; re: RegExp; requires?: RegExp }[] = [
|
|
951
959
|
{ cat: 'FILTER', re: /\b(categor(y|ies)|sub-?categor\w*|expand\w*|collaps\w*|\bfacet\b|filter\w*|belongs? to)\b/ },
|
|
952
960
|
{ cat: 'CART', re: /\b(add(ed|ing)? to cart|added (dialog|modal|message|confirmation)|cart (page|table|line|item)|view cart|continue shopping|quantit)\b/ },
|
|
953
961
|
{ cat: 'PRODUCT', re: /\b(product detail|product-detail|view product|product page|same product|product name and price|product identity|detail product)\b/ },
|
|
@@ -959,9 +967,16 @@ const CATEGORY_SIGNALS: { cat: string; re: RegExp }[] = [
|
|
|
959
967
|
// SEC, so the linter told QA to re-tag genuine auth-gate tests as navigation — diluting the
|
|
960
968
|
// visibility of the security suite (QA field report, 3.2.23). The guard vocabulary below is
|
|
961
969
|
// what makes the INTENT matchable, and GUARD_INTENT then wins over a mere observable.
|
|
962
|
-
{ cat: 'SEC', re: /\b(xss|sql|injection|payload|without authentication|unauthenticated|without an? (?:authenticated|valid|active) \w+|direct(?:ly)? access\w*|
|
|
970
|
+
{ cat: 'SEC', re: /\b(xss|sql|injection|payload|without authentication|unauthenticated|without an? (?:authenticated|valid|active) \w+|direct(?:ly)? access\w*|auth[- ]?guard|not skippable|without having (?:completed|submitted|logged)|remains? unauthenticated|\binert\b|tamper\w*|malformed|special character|abuse)\b/ },
|
|
971
|
+
// Robustness and routing words are SEC evidence only beside an ACCESS word. Alone they describe
|
|
972
|
+
// routing — "Unknown route shows the 404 page", "Deep link opens the item", "the tab from the url
|
|
973
|
+
// parameter" — and the linter told QA to re-tag those navigation tests as VP-SEC (#888).
|
|
974
|
+
{ cat: 'SEC', re: /\b(deep[- ]link\w*|url parameter|query param\w*|falls? back|gracefull?y|without a crash|not[- ]found|\b404\b)\b/, requires: ACCESS_CONTEXT },
|
|
963
975
|
];
|
|
964
976
|
|
|
977
|
+
const signalMatches = (c: { re: RegExp; requires?: RegExp }, title: string): boolean =>
|
|
978
|
+
c.re.test(title) && (!c.requires || c.requires.test(title));
|
|
979
|
+
|
|
965
980
|
/**
|
|
966
981
|
* Categories whose signal describes the test's INTENT/risk rather than the observable action.
|
|
967
982
|
* When one of these matches, a competing observable-only category (NAV: "redirects", "navigates")
|
|
@@ -979,14 +994,19 @@ export function taxonomyLint(scenarios: ScenarioInfo[]): TaxonomyResult {
|
|
|
979
994
|
if (!cat || !TAXONOMY_KNOWN.has(cat)) continue;
|
|
980
995
|
checked++;
|
|
981
996
|
const title = s.name.toLowerCase();
|
|
982
|
-
const matched = [...new Set(CATEGORY_SIGNALS.filter((c) => c
|
|
997
|
+
const matched = [...new Set(CATEGORY_SIGNALS.filter((c) => signalMatches(c, title)).map((c) => c.cat))];
|
|
983
998
|
if (matched.includes(cat)) continue; // title supports its own category → fine
|
|
999
|
+
// A gate (`requires`) only stops a word from SUGGESTING its category. An author who already
|
|
1000
|
+
// chose that category is supported by the word alone: a VP-SEC "Deep link to the filtered
|
|
1001
|
+
// list …" stays SEC instead of being pushed toward FILTER (#888 review).
|
|
1002
|
+
if (CATEGORY_SIGNALS.some((c) => c.cat === cat && c.requires && c.re.test(title))) continue;
|
|
984
1003
|
// The declared category may be an INTENT category whose signal this title spells
|
|
985
1004
|
// differently; never "correct" it toward a category that only names the observable.
|
|
986
1005
|
const suppressed = INTENT_OVER_OBSERVABLE[cat];
|
|
987
1006
|
const others = matched.filter((c) => c !== cat && !(suppressed && suppressed.has(c)));
|
|
988
1007
|
if (others.length === 1) { // unambiguous mismatch
|
|
989
|
-
|
|
1008
|
+
// The entry that MATCHED — a category can have several (SEC: guard words, gated robustness words).
|
|
1009
|
+
const sig = CATEGORY_SIGNALS.find((c) => c.cat === others[0] && signalMatches(c, title))!;
|
|
990
1010
|
const m = title.match(sig.re);
|
|
991
1011
|
mislabeled.push({ name: s.name, current: cat, suggested: others[0], signal: m ? m[0] : others[0] });
|
|
992
1012
|
}
|
|
@@ -1058,9 +1078,8 @@ export function numericClaimGaps(scenarios: ScenarioInfo[]): Array<{ name: strin
|
|
|
1058
1078
|
if (!m) continue;
|
|
1059
1079
|
const claimed = NUMBER_WORDS[m[1]] ?? Number(m[1]);
|
|
1060
1080
|
if (!Number.isFinite(claimed) || claimed < 2) continue;
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1063
|
-
const asserted = (s.stepsText.match(/\bsee \[[^\]]+\][^|]*?\b(?:with|contains) \{\{/g) ?? []).length;
|
|
1081
|
+
// Values, positive states and indicator presence count; plain text visibility does not (#866).
|
|
1082
|
+
const asserted = countedAssertions(s.stepsText);
|
|
1064
1083
|
if (asserted > 0 && asserted < claimed) out.push({ name: s.name, claimed, asserted, noun: m[2] });
|
|
1065
1084
|
}
|
|
1066
1085
|
return out;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triage signal library — the ordered message patterns `sungen triage` matches a red scenario's
|
|
3
|
+
* error against. Seeded from the repair rules (`harness/repair.ts`, driver-mobile `mobile-repair.ts`);
|
|
4
|
+
* the patterns are COPIED, not imported, because core never loads a driver at runtime. The parity
|
|
5
|
+
* check in tests/harness/triage-classification.run.ts keeps the environment rules in step.
|
|
6
|
+
*
|
|
7
|
+
* Order matters and is the classification order: environment first (nothing about the test or the
|
|
8
|
+
* app can be judged when the session never started), then test defects (the test never reached the
|
|
9
|
+
* app). Assertions are decided in triage.ts from the parsed Expected/Received, not here.
|
|
10
|
+
*/
|
|
11
|
+
export type SignalClass = 'environment' | 'test-defect';
|
|
12
|
+
|
|
13
|
+
export interface TriageSignal {
|
|
14
|
+
id: string;
|
|
15
|
+
class: SignalClass;
|
|
16
|
+
match: RegExp;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const TRIAGE_SIGNALS: TriageSignal[] = [
|
|
20
|
+
// --- environment: the run could not reach a usable app/session -----------------------------------
|
|
21
|
+
{ id: 'env-network', class: 'environment', match: /net::ERR_[A-Z_]+|\bECONNREFUSED\b|\bENOTFOUND\b|\bEAI_AGAIN\b|\bETIMEDOUT\b/ },
|
|
22
|
+
// mirrors mobile-session-not-created
|
|
23
|
+
{ id: 'env-session-not-created', class: 'environment', match: /A new session could not be created|session not created/i },
|
|
24
|
+
// mirrors mobile-driver-proxy-lost
|
|
25
|
+
{ id: 'env-driver-proxy-lost', class: 'environment', match: /Could not proxy command to (?:the )?remote server|socket hang up|ECONNRESET/ },
|
|
26
|
+
// mirrors mobile-instrumentation-failed
|
|
27
|
+
{ id: 'env-instrumentation-failed', class: 'environment', match: /instrumentation process (?:cannot|could not) be (?:initialized|started)|Cannot start the instrumentation process/ },
|
|
28
|
+
// mirrors mobile-chromedriver-mismatch
|
|
29
|
+
{ id: 'env-chromedriver-mismatch', class: 'environment', match: /No Chromedriver found|chromedriver.*(?:version|mismatch)|Chrome version must be/i },
|
|
30
|
+
// mirrors mobile-webview-context-absent
|
|
31
|
+
{ id: 'env-webview-context-absent', class: 'environment', match: /no WEBVIEW\/CHROMIUM context appeared within/ },
|
|
32
|
+
{ id: 'env-auth-state-missing', class: 'environment', match: /storageState|\.auth\/[\w.-]+\.json|ENOENT[^\n]*\.auth/ },
|
|
33
|
+
{ id: 'env-base-url-unresolved', class: 'environment', match: /Cannot navigate to invalid URL|baseURL (?:is )?(?:not set|undefined)|Invalid URL/ },
|
|
34
|
+
{ id: 'env-browser-closed', class: 'environment', match: /Target page, context or browser has been closed|browserType\.launch/ },
|
|
35
|
+
|
|
36
|
+
// --- test defect: the test could not reach the element or its data -------------------------------
|
|
37
|
+
{ id: 'test-strict-mode', class: 'test-defect', match: /strict mode violation/ },
|
|
38
|
+
{ id: 'test-locator-unresolved', class: 'test-defect', match: /locator\.\w+: [^\n]*(?:Timeout|timeout)[^\n]*exceeded[\s\S]*waiting for |resolved to 0 elements/ },
|
|
39
|
+
{ id: 'test-element-absent', class: 'test-defect', match: /no such element|Element not found:|An element could not be located/ },
|
|
40
|
+
{ id: 'test-data-missing', class: 'test-defect', match: /Test data key not found:|@cases dataset "[^"]+" not found/ },
|
|
41
|
+
{ id: 'test-step-unmatched', class: 'test-defect', match: /\[sungen\] step "[^"]*" matched no pattern|\bSG\d{3}\b/ },
|
|
42
|
+
{ id: 'test-tap-intercepted', class: 'test-defect', match: /element click intercepted|is not clickable at point|element is obscured|intercepts pointer events/i },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** A failure that is only Playwright's test-level timeout — no locator, no assertion detail (→ unclassified). */
|
|
46
|
+
export const BARE_TIMEOUT = /^(?:Error:\s*)?Test timeout of \d+ms exceeded\.?$/;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sungen triage` IO: find a unit's feature(s) and result file(s), join them on the same seam
|
|
3
|
+
* delivery and the dashboard use (`mergeFeatureAndSpec` + `resolveResultVariants`), classify each
|
|
4
|
+
* scenario, and persist `.sungen/triage/<unit>.json`. Read-only apart from that one report file.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { parseFeatureMetadata } from '../exporters/feature-parser';
|
|
9
|
+
import { loadPlaywrightReport, resolveResultsPath } from '../exporters/playwright-report-parser';
|
|
10
|
+
import { collectQaEnvSecretDenylist } from '../exporters/qa-env-secret-denylist';
|
|
11
|
+
import { isSensitiveKey } from '../exporters/sensitive-key';
|
|
12
|
+
import { resolveResultVariants } from '../exporters/result-variants';
|
|
13
|
+
import { mergeFeatureAndSpec } from '../exporters/scenario-merger';
|
|
14
|
+
import { parseSpecFile } from '../exporters/spec-parser';
|
|
15
|
+
import { loadTestData } from '../exporters/test-data-resolver';
|
|
16
|
+
import type { ResolvedUnit } from '../cli/resolve-unit';
|
|
17
|
+
import { classifyScenario, TriageClass, TriageRow } from './triage';
|
|
18
|
+
|
|
19
|
+
export interface TriageReport {
|
|
20
|
+
unit: string;
|
|
21
|
+
generatedAt: string;
|
|
22
|
+
/** Result files read, relative to the project root. Empty → nothing has run yet. */
|
|
23
|
+
resultFiles: string[];
|
|
24
|
+
counts: Partial<Record<TriageClass, number>>;
|
|
25
|
+
rows: TriageRow[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Values that must never reach a triage evidence line or a defect record: the resolved `${QA_*}`
|
|
30
|
+
* secrets AND the literal values of credential-named test-data keys — an assertion on a password
|
|
31
|
+
* field prints that value in its Expected/Received.
|
|
32
|
+
*/
|
|
33
|
+
export function unitSecrets(testData: Record<string, string>, cwd: string): string[] {
|
|
34
|
+
const literal = Object.entries(testData)
|
|
35
|
+
.filter(([k, v]) => isSensitiveKey(k) && typeof v === 'string' && v.length >= 3 && !v.includes('${'))
|
|
36
|
+
.map(([, v]) => String(v));
|
|
37
|
+
return [...new Set([...collectQaEnvSecretDenylist(testData, cwd).values, ...literal])].sort((a, b) => b.length - a.length);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function generatedDir(cwd: string, u: ResolvedUnit): string {
|
|
41
|
+
return path.join(cwd, 'specs', 'generated', u.unitId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every result file of one feature, labelled by variant: `<base>-test-result.json` (no label),
|
|
46
|
+
* `<base>-test-result.<os|env>[.<env>].json` (label = the middle part). A mobile unit has only
|
|
47
|
+
* per-OS files, so all of them are read rather than the first one found. Falls back to the shared
|
|
48
|
+
* lookup (incl. `test-results/results.json`) when the generated dir holds none.
|
|
49
|
+
*/
|
|
50
|
+
function resultFilesFor(cwd: string, genDir: string, base: string): Array<{ file: string; variant?: string }> {
|
|
51
|
+
const prefix = `${base}-test-result`;
|
|
52
|
+
const own = fs.existsSync(genDir)
|
|
53
|
+
? fs.readdirSync(genDir).filter((f) => f.startsWith(prefix) && f.endsWith('.json')).sort()
|
|
54
|
+
: [];
|
|
55
|
+
if (own.length > 0) {
|
|
56
|
+
return own.map((f) => {
|
|
57
|
+
const variant = f.slice(prefix.length, -'.json'.length).replace(/^\./, '');
|
|
58
|
+
return { file: path.join(genDir, f), ...(variant ? { variant } : {}) };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const shared = resolveResultsPath(cwd, genDir, base);
|
|
62
|
+
return shared ? [{ file: shared }] : [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function triageUnit(cwd: string, u: ResolvedUnit): TriageReport {
|
|
66
|
+
const genDir = generatedDir(cwd, u);
|
|
67
|
+
const featuresDir = path.join(u.dir, 'features');
|
|
68
|
+
const features = fs.existsSync(featuresDir) ? fs.readdirSync(featuresDir).filter((f) => f.endsWith('.feature')).sort() : [];
|
|
69
|
+
const rows: TriageRow[] = [];
|
|
70
|
+
const resultFiles: string[] = [];
|
|
71
|
+
|
|
72
|
+
for (const f of features) {
|
|
73
|
+
const base = f.replace(/\.feature$/, '');
|
|
74
|
+
const files = resultFilesFor(cwd, genDir, base);
|
|
75
|
+
if (files.length === 0) continue;
|
|
76
|
+
const merged = mergeFeatureAndSpec(parseFeatureMetadata(path.join(featuresDir, f)), parseSpecFile(path.join(genDir, `${base}.spec.ts`)));
|
|
77
|
+
const dataFile = path.join(u.dir, 'test-data', `${base}.yaml`);
|
|
78
|
+
const testData = fs.existsSync(dataFile) ? loadTestData(dataFile) : {};
|
|
79
|
+
const secrets = unitSecrets(testData, cwd);
|
|
80
|
+
|
|
81
|
+
for (const { file, variant } of files) {
|
|
82
|
+
resultFiles.push(path.relative(cwd, file));
|
|
83
|
+
const results = loadPlaywrightReport(file);
|
|
84
|
+
for (const m of merged) {
|
|
85
|
+
for (const v of resolveResultVariants(m, results, testData)) {
|
|
86
|
+
const r = classifyScenario({ unit: u.unitId, title: `${m.feature.name}${v.nameSuffix}`, tags: m.feature.tags, result: v.result, variant }, secrets);
|
|
87
|
+
if (r) rows.push(r);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const counts: Partial<Record<TriageClass, number>> = {};
|
|
94
|
+
for (const r of rows) counts[r.class] = (counts[r.class] ?? 0) + 1;
|
|
95
|
+
return { unit: u.unitId, generatedAt: new Date().toISOString(), resultFiles, counts, rows };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function triageReportPath(cwd: string, unitId: string): string {
|
|
99
|
+
return path.join(cwd, '.sungen', 'triage', `${unitId.replace(/\//g, '-')}.json`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The last triage of a unit, or null when `sungen triage` has not run for it. */
|
|
103
|
+
export function readTriageReport(cwd: string, unitId: string): TriageReport | null {
|
|
104
|
+
try { return JSON.parse(fs.readFileSync(triageReportPath(cwd, unitId), 'utf-8')) as TriageReport; } catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Persist under `.sungen/triage/<unit>.json` (a flow `flows/x` → `flows-x.json`); returns the path. */
|
|
108
|
+
export function writeTriageReport(cwd: string, report: TriageReport): string {
|
|
109
|
+
const out = triageReportPath(cwd, report.unit);
|
|
110
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
111
|
+
fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`, 'utf-8');
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sungen triage` — classify each red or flaky scenario of a run into exactly one class, with the
|
|
3
|
+
* rule that fired and one evidence line. Deterministic and read-only; it reports, it never gates.
|
|
4
|
+
*
|
|
5
|
+
* Order (first match wins):
|
|
6
|
+
* 1. known-defect `@known-defect` / `@known-defect:<id>` — an expected red.
|
|
7
|
+
* 2. blocked-capability `@manual:*` / `@requires:*`, or skipped by design.
|
|
8
|
+
* 3. environment the session or network never gave the test a usable app.
|
|
9
|
+
* 4. test-defect the test could not reach its element or data.
|
|
10
|
+
* 5. product-candidate an assertion whose Expected and Received both exist and differ.
|
|
11
|
+
* 6. unclassified anything else — left for the agent or QA, with the raw first message.
|
|
12
|
+
* A `passed` scenario that needed a retry is `flaky` (green, listed separately).
|
|
13
|
+
*
|
|
14
|
+
* 5 is tested before 3 and 4 in code: a parsed Expected/Received pair that differs means the app
|
|
15
|
+
* answered, which outranks any wrapper text (a test timeout around the assertion included).
|
|
16
|
+
*
|
|
17
|
+
* A bare test timeout (no locator, no assertion) is `unclassified` too: a slow environment, a page
|
|
18
|
+
* that hangs on a product bug and a test waiting on the wrong thing all print the same line.
|
|
19
|
+
*
|
|
20
|
+
* An assertion on an element that never appeared (`Received: <element(s) not found>`) is NOT a test
|
|
21
|
+
* defect: a wrong selector and a missing message look identical, so it is left for judgment rather
|
|
22
|
+
* than hiding a possible product bug behind "fix the selector".
|
|
23
|
+
*/
|
|
24
|
+
import { redactSecrets } from '../exporters/api-testcase-formatter';
|
|
25
|
+
import { parseExpectedReceived } from '../exporters/playwright-report-parser';
|
|
26
|
+
import type { PlaywrightResult } from '../exporters/types';
|
|
27
|
+
import { BARE_TIMEOUT, TRIAGE_SIGNALS } from './triage-signals';
|
|
28
|
+
|
|
29
|
+
export type TriageClass =
|
|
30
|
+
| 'known-defect' | 'blocked-capability' | 'environment' | 'test-defect' | 'product-candidate' | 'unclassified' | 'flaky';
|
|
31
|
+
|
|
32
|
+
export interface TriageInput {
|
|
33
|
+
unit: string;
|
|
34
|
+
title: string;
|
|
35
|
+
tags: string[];
|
|
36
|
+
result?: PlaywrightResult;
|
|
37
|
+
/** Result-file variant (`android`, `ios`, an env name) when the unit has several. */
|
|
38
|
+
variant?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TriageRow {
|
|
42
|
+
unit: string;
|
|
43
|
+
scenario: string;
|
|
44
|
+
variant?: string;
|
|
45
|
+
class: TriageClass;
|
|
46
|
+
ruleId: string;
|
|
47
|
+
evidence: string;
|
|
48
|
+
ids: string[];
|
|
49
|
+
legacy: string[];
|
|
50
|
+
defectId?: string;
|
|
51
|
+
tracePath?: string;
|
|
52
|
+
/** product-candidate only: the two halves of the assertion, for a defect draft. */
|
|
53
|
+
expected?: string;
|
|
54
|
+
received?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const EVIDENCE_MAX = 200;
|
|
58
|
+
const VIEWPOINT_ID = /\b(?:VP|FL)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi;
|
|
59
|
+
const ELEMENT_ABSENT = /Received:\s*<element\(s\) not found>/;
|
|
60
|
+
|
|
61
|
+
/** The part of a message the run itself wrote: before `Call log:`, without `Locator:` echo lines. */
|
|
62
|
+
const runHead = (m: string): string => m.split(/\n\s*Call log:/)[0].replace(/^\s*Locator:.*$/gm, '');
|
|
63
|
+
|
|
64
|
+
/** Every failure message, stack frames and a leading `Error:` dropped. */
|
|
65
|
+
function messagesOf(result: PlaywrightResult): string[] {
|
|
66
|
+
const raw = result.errorMessages?.length ? result.errorMessages : [result.error ?? ''];
|
|
67
|
+
return raw.map((m) => m.split(/\n\s*at\s/)[0].replace(/^Error:\s*/i, '').trim()).filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function evidenceLine(text: string, secrets: string[]): string {
|
|
71
|
+
const flat = redactSecrets(text, secrets).replace(/\s+/g, ' ').trim();
|
|
72
|
+
return flat.length > EVIDENCE_MAX ? `${flat.slice(0, EVIDENCE_MAX - 1)}…` : flat;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Classify one scenario. Null when it needs no triage (clean pass, or never ran). */
|
|
76
|
+
export function classifyScenario(input: TriageInput, secrets: string[] = []): TriageRow | null {
|
|
77
|
+
const { result, tags } = input;
|
|
78
|
+
if (!result) return null;
|
|
79
|
+
const base = {
|
|
80
|
+
unit: input.unit,
|
|
81
|
+
scenario: input.title,
|
|
82
|
+
...(input.variant ? { variant: input.variant } : {}),
|
|
83
|
+
ids: [...new Set([input.title, ...tags].join(' ').match(VIEWPOINT_ID) ?? [])],
|
|
84
|
+
legacy: tags.filter((t) => t.startsWith('@legacy:')).map((t) => t.slice('@legacy:'.length)),
|
|
85
|
+
...(result.tracePath ? { tracePath: result.tracePath } : {}),
|
|
86
|
+
};
|
|
87
|
+
const row = (cls: TriageClass, ruleId: string, evidence: string): TriageRow =>
|
|
88
|
+
({ ...base, class: cls, ruleId, evidence: evidenceLine(evidence, secrets) });
|
|
89
|
+
|
|
90
|
+
if (result.status === 'passed') {
|
|
91
|
+
return (result.attempts ?? 1) > 1 ? row('flaky', 'passed-after-retry', `passed on attempt ${result.attempts}`) : null;
|
|
92
|
+
}
|
|
93
|
+
const messages = messagesOf(result);
|
|
94
|
+
const first = messages[0] ?? result.status;
|
|
95
|
+
|
|
96
|
+
const known = tags.find((t) => t === '@known-defect' || t.startsWith('@known-defect:'));
|
|
97
|
+
if (known) {
|
|
98
|
+
const r = row('known-defect', 'tag-known-defect', first);
|
|
99
|
+
const id = known.split(':')[1];
|
|
100
|
+
return id ? { ...r, defectId: id } : r;
|
|
101
|
+
}
|
|
102
|
+
if (tags.some((t) => t.startsWith('@manual'))) return row('blocked-capability', 'tag-manual', `${tags.find((t) => t.startsWith('@manual'))} — ${first}`);
|
|
103
|
+
if (tags.some((t) => t.startsWith('@requires:'))) return row('blocked-capability', 'tag-requires', `${tags.find((t) => t.startsWith('@requires:'))} — ${first}`);
|
|
104
|
+
if (result.status === 'skipped') return row('blocked-capability', 'status-skipped', 'skipped');
|
|
105
|
+
|
|
106
|
+
// An assertion with both halves decides the case even when a timeout wraps it.
|
|
107
|
+
for (const m of messages) {
|
|
108
|
+
const { expected, received } = parseExpectedReceived(m);
|
|
109
|
+
if (expected && received && !ELEMENT_ABSENT.test(m) && expected !== received) {
|
|
110
|
+
return { ...row('product-candidate', 'assertion-mismatch', `Expected ${expected} · Received ${received}`),
|
|
111
|
+
expected: evidenceLine(expected, secrets), received: evidenceLine(received, secrets) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const s of TRIAGE_SIGNALS) {
|
|
115
|
+
// Environment signals read only what the RUN said, never the test's own selector text that a
|
|
116
|
+
// message echoes in `Locator:` / `Call log:` — `getByText('Invalid URL')` is a missing message,
|
|
117
|
+
// not a bad base URL. Test-defect signals need the call log (`waiting for …`), so they read it all.
|
|
118
|
+
const hit = messages.find((m) => s.match.test(s.class === 'environment' ? runHead(m) : m));
|
|
119
|
+
if (hit) return row(s.class, s.id, hit);
|
|
120
|
+
}
|
|
121
|
+
const absent = messages.find((m) => ELEMENT_ABSENT.test(m));
|
|
122
|
+
if (absent) return row('unclassified', 'assertion-element-absent', absent);
|
|
123
|
+
if (messages.length > 0 && messages.every((m) => BARE_TIMEOUT.test(m))) {
|
|
124
|
+
return row('unclassified', 'bare-timeout', `${first} No locator or assertion detail: a slow environment, a hanging page, or a wait in the test. Re-run once, then read the trace's last action.`);
|
|
125
|
+
}
|
|
126
|
+
return row('unclassified', 'no-signal', first);
|
|
127
|
+
}
|
|
@@ -12,6 +12,7 @@ import * as fs from 'fs';
|
|
|
12
12
|
import { NON_DECLARATION_SECTION, PRIORITY_WORDS, ScenarioInfo } from './parse';
|
|
13
13
|
import { readViewpointText } from './read-text';
|
|
14
14
|
import { keywordHit } from './keyword-match';
|
|
15
|
+
import { countedAssertions } from './counted-assertions';
|
|
15
16
|
|
|
16
17
|
export interface LedgerItem { id?: string; text: string; covered: boolean }
|
|
17
18
|
|
|
@@ -238,8 +239,7 @@ export function viewpointLedger(viewpointPath: string, scenarios: ScenarioInfo[]
|
|
|
238
239
|
const n = claimedCount(item.text);
|
|
239
240
|
const auto = carriers.filter((s) => !s.manual);
|
|
240
241
|
if (n !== null && auto.length > 0 && !auto.some((s) => /\bsee all\b/.test(s.stepsText))) {
|
|
241
|
-
const asserted = Math.max(...auto.map((s) =>
|
|
242
|
-
(s.stepsText.match(/\bsee \[[^\]]+\][^|]*?\b(?:with|contains) \{\{/g) ?? []).length));
|
|
242
|
+
const asserted = Math.max(...auto.map((s) => countedAssertions(s.stepsText)));
|
|
243
243
|
if (asserted > 0 && asserted < n) partial.push({ id: item.id, text: item.text, claimed: n, asserted });
|
|
244
244
|
}
|
|
245
245
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,8 @@ export type { ResolvedAppProfile } from './generators/test-generator/app-profile
|
|
|
20
20
|
export type { MappedStep } from './generators/test-generator/step-mapper';
|
|
21
21
|
// The shared "nothing honest to compile" step body. Exported for the driver SPI: a pattern that
|
|
22
22
|
// must refuse (SG-W017 and siblings) emits THIS rather than shipping a one-sided refusal template,
|
|
23
|
-
// which would fail the adapter-parity gate and push playwright off its refusal-only floor
|
|
23
|
+
// which would fail the adapter-parity gate and push playwright off its refusal-only floor (1 — the
|
|
24
|
+
// `date-picker-unsupported` refusal both adapters ship for the resolver-based fill-input pattern, #907).
|
|
24
25
|
export { unsupportedStepCode } from './generators/test-generator/patterns/unsupported-step-code';
|
|
25
26
|
export type { ParsedStep } from './generators/gherkin-parser';
|
|
26
27
|
export { getPathCode, inferPath, resolvePathVariables } from './generators/test-generator/utils/path-inference';
|