@sun-asterisk/sungen 3.2.31 → 3.2.32
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 +3 -1
- 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 +11 -0
- package/dist/cli/commands/capability.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +2 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/setup-mobile-env.d.ts +15 -0
- package/dist/cli/commands/setup-mobile-env.d.ts.map +1 -0
- package/dist/cli/commands/setup-mobile-env.js +65 -0
- package/dist/cli/commands/setup-mobile-env.js.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-assertion.hbs +1 -2
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-element.hbs +8 -5
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/count-assertion.hbs +5 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-filter-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-filter-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/list-item-count-assertion.hbs +5 -2
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/partials/await-elements.hbs +12 -0
- package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +26 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/capture-variable.hbs +7 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/row-scoped-column-assertion.hbs +23 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +4 -4
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/default.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/id.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +4 -4
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +13 -2
- package/dist/generators/test-generator/adapters/playwright/templates/test-file.hbs +14 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +56 -13
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +39 -2
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +38 -1
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/patterns/index.d.ts +1 -1
- package/dist/generators/test-generator/patterns/index.js +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts +35 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +103 -4
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +18 -0
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts +47 -0
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +17 -3
- 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 +23 -4
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/next-step.d.ts.map +1 -1
- package/dist/harness/next-step.js +2 -1
- package/dist/harness/next-step.js.map +1 -1
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +42 -7
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/spec-branches.d.ts +7 -2
- package/dist/harness/spec-branches.d.ts.map +1 -1
- package/dist/harness/spec-branches.js +62 -11
- package/dist/harness/spec-branches.js.map +1 -1
- package/dist/harness/spec-coverage.d.ts +16 -0
- package/dist/harness/spec-coverage.d.ts.map +1 -1
- package/dist/harness/spec-coverage.js +119 -10
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/orchestrator/appium-environment-setup.d.ts +24 -0
- package/dist/orchestrator/appium-environment-setup.d.ts.map +1 -0
- package/dist/orchestrator/appium-environment-setup.js +157 -0
- package/dist/orchestrator/appium-environment-setup.js.map +1 -0
- package/dist/orchestrator/mobile-runtime-scaffolder.d.ts +2 -0
- package/dist/orchestrator/mobile-runtime-scaffolder.d.ts.map +1 -1
- package/dist/orchestrator/mobile-runtime-scaffolder.js.map +1 -1
- package/dist/orchestrator/project-initializer.d.ts +1 -0
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +14 -0
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +5 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +55 -2
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +20 -5
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +3 -3
- package/dist/orchestrator/templates/appium-environment/README.md +83 -0
- package/dist/orchestrator/templates/appium-environment/scripts/common/install-appium.ps1 +44 -0
- package/dist/orchestrator/templates/appium-environment/scripts/common/install-appium.sh +50 -0
- package/dist/orchestrator/templates/appium-environment/scripts/common/lib.ps1 +108 -0
- package/dist/orchestrator/templates/appium-environment/scripts/common/lib.sh +141 -0
- package/dist/orchestrator/templates/appium-environment/scripts/common/verify-appium.ps1 +39 -0
- package/dist/orchestrator/templates/appium-environment/scripts/common/verify-appium.sh +43 -0
- package/dist/orchestrator/templates/appium-environment/scripts/common/verify-node.ps1 +13 -0
- package/dist/orchestrator/templates/appium-environment/scripts/common/verify-node.sh +16 -0
- package/dist/orchestrator/templates/appium-environment/scripts/macos/install-android.sh +110 -0
- package/dist/orchestrator/templates/appium-environment/scripts/macos/install-ios.sh +32 -0
- package/dist/orchestrator/templates/appium-environment/scripts/macos/verify-android.sh +32 -0
- package/dist/orchestrator/templates/appium-environment/scripts/macos/verify-ios.sh +15 -0
- package/dist/orchestrator/templates/appium-environment/scripts/windows/install-android.ps1 +153 -0
- package/dist/orchestrator/templates/appium-environment/scripts/windows/verify-android.ps1 +26 -0
- package/dist/orchestrator/templates/appium-environment/setup.ps1 +58 -0
- package/dist/orchestrator/templates/appium-environment/setup.sh +88 -0
- package/dist/orchestrator/templates/specs-checked-state.d.ts +1 -1
- package/dist/orchestrator/templates/specs-checked-state.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-checked-state.js +26 -3
- package/dist/orchestrator/templates/specs-checked-state.js.map +1 -1
- package/dist/orchestrator/templates/specs-checked-state.ts +31 -6
- package/dist/orchestrator/templates/specs-mock.d.ts +2 -1
- package/dist/orchestrator/templates/specs-mock.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-mock.js +10 -3
- package/dist/orchestrator/templates/specs-mock.js.map +1 -1
- package/dist/orchestrator/templates/specs-mock.ts +12 -4
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +2 -1
- package/src/cli/commands/capability.ts +12 -1
- package/src/cli/commands/init.ts +3 -0
- package/src/cli/commands/setup-mobile-env.ts +64 -0
- package/src/cli/index.ts +2 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-assertion.hbs +1 -2
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-element.hbs +8 -5
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/count-assertion.hbs +5 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-filter-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-filter-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/list-item-count-assertion.hbs +5 -2
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/partials/await-elements.hbs +12 -0
- package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +26 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/capture-variable.hbs +7 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/row-scoped-column-assertion.hbs +23 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +4 -4
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/default.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/id.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +4 -4
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +13 -2
- package/src/generators/test-generator/adapters/playwright/templates/test-file.hbs +14 -0
- package/src/generators/test-generator/code-generator.ts +66 -17
- package/src/generators/test-generator/diagnostics.ts +39 -2
- package/src/generators/test-generator/patterns/index.ts +1 -1
- package/src/generators/test-generator/step-mapper.ts +106 -5
- package/src/generators/test-generator/template-engine.ts +18 -0
- package/src/generators/test-generator/utils/selector-resolver.ts +18 -3
- package/src/harness/audit.ts +24 -5
- package/src/harness/next-step.ts +2 -1
- package/src/harness/parse.ts +42 -7
- package/src/harness/spec-branches.ts +67 -12
- package/src/harness/spec-coverage.ts +121 -11
- package/src/orchestrator/appium-environment-setup.ts +127 -0
- package/src/orchestrator/mobile-runtime-scaffolder.ts +2 -0
- package/src/orchestrator/project-initializer.ts +15 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +5 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +55 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +20 -5
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +3 -3
- package/src/orchestrator/templates/appium-environment/README.md +83 -0
- package/src/orchestrator/templates/appium-environment/scripts/common/install-appium.ps1 +44 -0
- package/src/orchestrator/templates/appium-environment/scripts/common/install-appium.sh +50 -0
- package/src/orchestrator/templates/appium-environment/scripts/common/lib.ps1 +108 -0
- package/src/orchestrator/templates/appium-environment/scripts/common/lib.sh +141 -0
- package/src/orchestrator/templates/appium-environment/scripts/common/verify-appium.ps1 +39 -0
- package/src/orchestrator/templates/appium-environment/scripts/common/verify-appium.sh +43 -0
- package/src/orchestrator/templates/appium-environment/scripts/common/verify-node.ps1 +13 -0
- package/src/orchestrator/templates/appium-environment/scripts/common/verify-node.sh +16 -0
- package/src/orchestrator/templates/appium-environment/scripts/macos/install-android.sh +110 -0
- package/src/orchestrator/templates/appium-environment/scripts/macos/install-ios.sh +32 -0
- package/src/orchestrator/templates/appium-environment/scripts/macos/verify-android.sh +32 -0
- package/src/orchestrator/templates/appium-environment/scripts/macos/verify-ios.sh +15 -0
- package/src/orchestrator/templates/appium-environment/scripts/windows/install-android.ps1 +153 -0
- package/src/orchestrator/templates/appium-environment/scripts/windows/verify-android.ps1 +26 -0
- package/src/orchestrator/templates/appium-environment/setup.ps1 +58 -0
- package/src/orchestrator/templates/appium-environment/setup.sh +88 -0
- package/src/orchestrator/templates/specs-checked-state.ts +31 -6
- package/src/orchestrator/templates/specs-mock.ts +12 -4
package/src/harness/parse.ts
CHANGED
|
@@ -78,6 +78,32 @@ function isViewpointId(s: string): boolean {
|
|
|
78
78
|
return isIdLike(s) || /^(?:VP|FL)-[A-Z][A-Z0-9]*$/i.test(s);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* A markdown heading's TEXT, normalised the way the ledger already normalises it.
|
|
83
|
+
*
|
|
84
|
+
* Headings in a real viewpoint file are numbered ("## 2. Priority Viewpoints") and often bolded
|
|
85
|
+
* ("## **Priority Viewpoints**"), and both forms are what the authoring template produces. Every
|
|
86
|
+
* pass below anchored on the bare literal, so a numbered file parsed to ZERO viewpoints: the
|
|
87
|
+
* priority table was dropped whole, clean rows included, and the audit lost its traceability
|
|
88
|
+
* table. `viewpoint-ledger.ts` learned the same lesson at its own heading test (#657); this is
|
|
89
|
+
* that normalisation, applied where the overview reads headings.
|
|
90
|
+
*
|
|
91
|
+
* Returns null for a line that is not a heading, so a caller can tell "not a heading" from
|
|
92
|
+
* "a heading whose text is empty".
|
|
93
|
+
*/
|
|
94
|
+
function headingParts(line: string): { level: number; text: string } | null {
|
|
95
|
+
const m = line.match(/^(#{1,6})\s+(.*)$/);
|
|
96
|
+
if (!m) return null;
|
|
97
|
+
const text = m[2]
|
|
98
|
+
.replace(/[*`]/g, '')
|
|
99
|
+
// Multi-level (`2.1`, `3.2.4`) and roman numbering are at least as common in a numbered spec as
|
|
100
|
+
// a single digit, and a strip that only handled one level left `## 2.1 Priority Viewpoints`
|
|
101
|
+
// reading as `1 Priority Viewpoints` — the same zero-viewpoint failure, one dot away.
|
|
102
|
+
.replace(/^\s*(?:\d+(?:\.\d+)*|[IVXLCDM]+|[A-Z])\s*[.):]?\s+/, '')
|
|
103
|
+
.trim();
|
|
104
|
+
return { level: m[1].length, text };
|
|
105
|
+
}
|
|
106
|
+
|
|
81
107
|
export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
82
108
|
if (!fs.existsSync(filePath)) return [];
|
|
83
109
|
const text = readTextFile(filePath);
|
|
@@ -89,12 +115,15 @@ export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
|
89
115
|
let inPriorityTable = false;
|
|
90
116
|
for (const raw of lines) {
|
|
91
117
|
const line = raw.trim();
|
|
92
|
-
|
|
93
|
-
if (
|
|
118
|
+
const heading = headingParts(line);
|
|
119
|
+
if (heading && heading.level === 2 && /^Priority Viewpoints/i.test(heading.text)) { inPriorityTable = true; continue; }
|
|
120
|
+
if (inPriorityTable && heading && heading.level === 2) { inPriorityTable = false; }
|
|
94
121
|
if (inPriorityTable && line.startsWith('|')) {
|
|
95
122
|
const cells = line.split('|').map((c) => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1);
|
|
96
123
|
if (cells.length >= 3) {
|
|
97
|
-
|
|
124
|
+
// The same `**` the heading carries: ids are routinely written `| **VP-LOGIC** | High | … |`,
|
|
125
|
+
// and an id read with its emphasis markers still attached matches no scenario tag.
|
|
126
|
+
const id = cells[0].replace(/[*`]/g, '').trim();
|
|
98
127
|
if (isViewpointId(id) && !/^-+$/.test(cells[1])) {
|
|
99
128
|
const pr = /high/i.test(cells[1]) ? 'High' : /medium/i.test(cells[1]) ? 'Medium' : /low/i.test(cells[1]) ? 'Low' : 'Unknown';
|
|
100
129
|
entries.set(id.toUpperCase(), { id: id.toUpperCase(), priority: pr as any, reason: cells[2] });
|
|
@@ -110,7 +139,10 @@ export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
|
110
139
|
// FL-HP-001 were reported as unmapped. Both forms are additive here.
|
|
111
140
|
for (const raw of lines) {
|
|
112
141
|
const line = raw.trim();
|
|
113
|
-
const
|
|
142
|
+
const head = headingParts(line);
|
|
143
|
+
const section = head && head.level === 2
|
|
144
|
+
? head.text.match(/^([A-Z]{2,}(?:-[A-Z0-9]{2,})*)\s+[—–-]\s*(.*)$/)
|
|
145
|
+
: null;
|
|
114
146
|
if (section && isViewpointId(section[1] + '-0')) {
|
|
115
147
|
const id = section[1].toUpperCase();
|
|
116
148
|
if (!entries.has(id)) entries.set(id, { id, priority: 'Unknown', reason: section[2] ?? '' });
|
|
@@ -130,11 +162,14 @@ export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
|
130
162
|
let group: ViewpointEntry['group'] | undefined;
|
|
131
163
|
for (const raw of lines) {
|
|
132
164
|
const line = raw.trim();
|
|
133
|
-
const
|
|
165
|
+
const head = headingParts(line);
|
|
166
|
+
const g = head && head.level === 3 ? head.text.match(/^(Required|Recommended|Optional)/i) : null;
|
|
134
167
|
if (g) { group = (g[1][0].toUpperCase() + g[1].slice(1).toLowerCase()) as any; continue; }
|
|
135
|
-
if (
|
|
168
|
+
if (head && head.level === 2) { group = undefined; }
|
|
136
169
|
if (group) {
|
|
137
|
-
|
|
170
|
+
// `**` and backticks only — a single `*` is a bullet marker here, and stripping it would
|
|
171
|
+
// stop `* VP-A-001` being a bullet at all.
|
|
172
|
+
const m = line.replace(/\*\*|`/g, '').match(/^[-*+]\s+([A-Za-z][A-Za-z0-9.-]*)/);
|
|
138
173
|
if (m && isViewpointId(m[1])) {
|
|
139
174
|
const id = m[1].toUpperCase();
|
|
140
175
|
const existing = entries.get(id);
|
|
@@ -204,38 +204,93 @@ export function sameOracleClusters(scenarios: ScenarioInfo[]): Array<{ oracle: s
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
|
-
* A "
|
|
207
|
+
* A "hidden" scenario with no "shown" counterpart (#630, widened in #679).
|
|
208
208
|
*
|
|
209
209
|
* Two security cases proved that chat content is hidden when `MESSAGE_READ` is denied. If the code
|
|
210
210
|
* hid it unconditionally — ignoring the permission entirely — both still pass. The deny side alone
|
|
211
211
|
* cannot distinguish "the guard works" from "the feature is broken for everyone", so the pair is
|
|
212
212
|
* the test, exactly as `min-1 / min / max / max+1` is the test for a boundary.
|
|
213
213
|
*
|
|
214
|
-
* The
|
|
214
|
+
* The first version gated on PERMISSION vocabulary, and "show A or B, never both" is far broader
|
|
215
|
+
* than permission: a conditional button, an empty-vs-populated state, an active tab. A field report
|
|
216
|
+
* lost exactly that case — "the score button is hidden once a score exists" was written, "it is
|
|
217
|
+
* shown while no score is set" was not, and nothing asked for it. So the trigger is now the
|
|
218
|
+
* ELEMENT the assertion names: an element this suite proves ABSENT and never proves PRESENT is
|
|
219
|
+
* unpaired, whatever the condition is called. That also catches the vacuous form — a selector that
|
|
220
|
+
* matches nothing passes `is hidden` forever.
|
|
221
|
+
*
|
|
222
|
+
* Permission stays its own `kind`, because the repair advice differs and its wording is what the
|
|
223
|
+
* skill teaches.
|
|
215
224
|
*/
|
|
216
225
|
const DENY_SHAPE = /\b(?:denied|deny|denies|forbidden|not authoriz\w*|unauthoriz\w*|no permission|without (?:the )?(?:permission|right|access|flag)|permission (?:is )?off|flag (?:is )?off|revoked|restricted)\b/i;
|
|
217
226
|
const HIDDEN_SHAPE = /\b(?:is hidden|are hidden|not (?:visible|shown|displayed)|blocked|masked|redacted|does not (?:show|render|appear))\b/i;
|
|
218
227
|
const ALLOW_SHAPE = /\b(?:granted|allowed|permitted|authoriz\w*|has (?:the )?(?:permission|right|access)|permission (?:is )?on|flag (?:is )?on|with (?:the )?(?:permission|right|access))\b/i;
|
|
219
228
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
/** Absence in sungen's own assertion grammar — `is hidden`, `does not exist`, `not exists`. */
|
|
230
|
+
const ABSENT_ASSERTION = /\bis hidden\b|\bare hidden\b|\bnot (?:visible|shown|displayed)\b|\bdoes not (?:exist|show|appear|render)\b|\bnot exists?\b/i;
|
|
231
|
+
|
|
232
|
+
export interface VisibilityPairGap {
|
|
233
|
+
scenario: string;
|
|
234
|
+
subject: string[];
|
|
235
|
+
/** The element reference the pair is about, when the assertion names one. */
|
|
236
|
+
element?: string;
|
|
237
|
+
/** `permission` keeps the deny/allow repair advice; `condition` is any other state. */
|
|
238
|
+
kind: 'permission' | 'condition';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What a scenario ASSERTS — the Then side. Hand-built fixtures carry no buckets; use their steps. */
|
|
242
|
+
function thenLines(s: ScenarioInfo): string[] {
|
|
243
|
+
const thens = (s.steps ?? []).filter((st) => st.bucket === 'then').map((st) => st.text.toLowerCase());
|
|
244
|
+
if (thens.length) return thens;
|
|
245
|
+
return s.stepsText ? [s.stepsText] : [];
|
|
246
|
+
}
|
|
247
|
+
/** Every `[ref]` of a line. */
|
|
248
|
+
function refsIn(line: string): string[] {
|
|
249
|
+
return [...line.matchAll(/\[([^\]]{1,60})\]/g)].map((m) => m[1].trim().toLowerCase());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function visibilityPairGaps(scenarios: ScenarioInfo[]): VisibilityPairGap[] {
|
|
253
|
+
// Every element the suite proves PRESENT, anywhere. A positive assertion names its element and
|
|
254
|
+
// any element it is scoped INSIDE (`[Row] row in [Table] table`) — both are demonstrably there.
|
|
255
|
+
const shown = new Set<string>();
|
|
256
|
+
for (const s of scenarios) {
|
|
257
|
+
for (const l of thenLines(s)) {
|
|
258
|
+
if (ABSENT_ASSERTION.test(l)) continue;
|
|
259
|
+
for (const ref of refsIn(l)) shown.add(ref);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
226
262
|
// Words only — the scenario ID is stripped first, because `[a-z][a-z-]{4,}` happily matches
|
|
227
263
|
// `fl-er-` and an id fragment can never appear in the counterpart's title, so demanding it made
|
|
228
264
|
// the pair impossible to find.
|
|
229
265
|
const distinctive = (s: ScenarioInfo): string[] =>
|
|
230
266
|
[...new Set((s.name.replace(/^\S+\s*/, '').toLowerCase().match(/[a-z]{5,}/g) ?? []))]
|
|
231
267
|
.filter((w) => !GENERIC_WORD.has(w) && !/denied|denies|hidden|permission|scenario/.test(w));
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
268
|
+
const allows = scenarios.filter((s) => ALLOW_SHAPE.test(s.haystack) && !HIDDEN_SHAPE.test(s.stepsText));
|
|
269
|
+
const out: VisibilityPairGap[] = [];
|
|
270
|
+
const reported = new Set<string>();
|
|
271
|
+
for (const s of scenarios) {
|
|
272
|
+
const absent = thenLines(s).filter((l) => ABSENT_ASSERTION.test(l));
|
|
273
|
+
if (!absent.length && !(DENY_SHAPE.test(s.haystack) && HIDDEN_SHAPE.test(s.haystack))) continue;
|
|
274
|
+
const kind = DENY_SHAPE.test(s.haystack) ? 'permission' : 'condition';
|
|
275
|
+
// Only the FIRST ref of an absence line is its subject: in `see [Row] row in [Table] table … is
|
|
276
|
+
// hidden` the table is the scope, the row is what went away.
|
|
277
|
+
const subjects = [...new Set(absent.map((l) => refsIn(l)[0]).filter((r): r is string => !!r))];
|
|
278
|
+
if (subjects.length) {
|
|
279
|
+
for (const el of subjects) {
|
|
280
|
+
if (shown.has(el) || reported.has(el)) continue;
|
|
281
|
+
reported.add(el);
|
|
282
|
+
out.push({ scenario: s.name, subject: distinctive(s).slice(0, 4), element: el, kind });
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
// The assertion names no element — the permission vocabulary is then the only safe trigger,
|
|
287
|
+
// paired on the scenario's distinctive title words as before.
|
|
288
|
+
if (kind !== 'permission' || !HIDDEN_SHAPE.test(s.haystack)) continue;
|
|
289
|
+
const subject = distinctive(s);
|
|
235
290
|
// The counterpart has to be about the SAME thing — two shared distinctive words, the same
|
|
236
291
|
// bar the ledger's prose matching uses.
|
|
237
292
|
const paired = allows.some((a) => subject.filter((w) => a.haystack.includes(w)).length >= Math.min(2, subject.length));
|
|
238
|
-
if (!paired) out.push({ scenario:
|
|
293
|
+
if (!paired) out.push({ scenario: s.name, subject: subject.slice(0, 4), kind: 'permission' });
|
|
239
294
|
}
|
|
240
295
|
return out;
|
|
241
296
|
}
|
|
@@ -16,6 +16,12 @@ export type Modality = 'MUST' | 'SHOULD' | 'MAY';
|
|
|
16
16
|
|
|
17
17
|
export interface TriggerGap { constraint: string; code: string; required: string[]; found: string[]; missing: string[] }
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* One DIRECTION of a requirement that states both — `MUST be shown … MUST NOT be shown …` (#679).
|
|
21
|
+
* The id is cited, so the requirement looks covered; this names the half nothing asserts.
|
|
22
|
+
*/
|
|
23
|
+
export interface ClauseGap { id: string; polarity: 'affirm' | 'negate'; text: string }
|
|
24
|
+
|
|
19
25
|
export interface SpecCoverageResult {
|
|
20
26
|
hasSpec: boolean;
|
|
21
27
|
frTotal: number;
|
|
@@ -27,10 +33,13 @@ export interface SpecCoverageResult {
|
|
|
27
33
|
* requirement table and any later refactor cannot follow it. */
|
|
28
34
|
inferredOnly: string[];
|
|
29
35
|
triggerGaps: TriggerGap[]; // per-constraint trigger matrix gaps
|
|
36
|
+
/** Requirements whose two opposing clauses are not BOTH proved (#679). */
|
|
37
|
+
clauseGaps: ClauseGap[];
|
|
30
38
|
verdict: 'pass' | 'warn' | 'fail';
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
interface
|
|
41
|
+
export interface FrDirection { polarity: 'affirm' | 'negate'; text: string }
|
|
42
|
+
interface FrClause { id: string; text: string; modality: Modality; directions?: FrDirection[] }
|
|
34
43
|
interface ValRow { constraint: string; code: string; triggers: string[] }
|
|
35
44
|
|
|
36
45
|
// Parsing the spec's Trigger CELL: loose word match ("blur, submit").
|
|
@@ -55,6 +64,86 @@ function modalityOf(text: string): Modality {
|
|
|
55
64
|
return 'MAY';
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A requirement that states BOTH directions in one sentence — "`X` MUST be shown when no score is
|
|
69
|
+
* set; MUST NOT be shown once a score exists" (#679).
|
|
70
|
+
*
|
|
71
|
+
* One `FrClause` per LINE carried one modality for the whole sentence, and coverage was a binary
|
|
72
|
+
* check per id: ONE scenario tagged `@spec:FR-013` closed both halves. The half that went missing
|
|
73
|
+
* in the field was the MUST NOT one — the classic "both buttons rendered at once" defect, reported
|
|
74
|
+
* as covered 100% on the first generation.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately narrow, three ways at once, because the finding is GATE-BLOCKING:
|
|
77
|
+
* - only a HARD prohibition paired with a hard obligation ("MUST be shown and enabled" is one
|
|
78
|
+
* direction and stays one unit);
|
|
79
|
+
* - only a requirement that NAMES its element, backticked or bracketed — the direction evidence
|
|
80
|
+
* is an assertion ABOUT something, and without a subject there is nothing to look for;
|
|
81
|
+
* - only a VISIBILITY/enabledness requirement, because that is the vocabulary the evidence test
|
|
82
|
+
* reads. "MUST validate the email format on submit. MUST NOT allow submission when it is
|
|
83
|
+
* invalid." is an opposing pair in prose and NOT one here: nothing in a correct suite for it
|
|
84
|
+
* asserts absence, so judging it would fail a suite with no gap in it.
|
|
85
|
+
*/
|
|
86
|
+
const VISIBILITY_CLAUSE = /\b(?:shown|shows?|displayed|displays?|visible|hidden|hides?|rendered|renders?|appears?|enabled|disabled)\b|表示|hiển thị/i;
|
|
87
|
+
const PROHIBITION = /\b(?:must not|must never|shall not|shall never)\b|KHÔNG ĐƯỢC|してはならない/i;
|
|
88
|
+
const OBLIGATION = /\b(?:must|shall)\b|\bPHẢI\b/i;
|
|
89
|
+
|
|
90
|
+
function splitDirections(text: string): FrDirection[] | undefined {
|
|
91
|
+
if (!VISIBILITY_CLAUSE.test(text) || elementRefs(text).length === 0) return undefined;
|
|
92
|
+
const parts = text.split(/\s*(?:;|;|。|\.\s+|,?\s+but\s+)\s*/i).map((p) => p.trim()).filter(Boolean);
|
|
93
|
+
if (parts.length < 2) return undefined;
|
|
94
|
+
const affirm: string[] = [];
|
|
95
|
+
const negate: string[] = [];
|
|
96
|
+
for (const part of parts) {
|
|
97
|
+
if (PROHIBITION.test(part)) negate.push(part);
|
|
98
|
+
else if (OBLIGATION.test(part)) affirm.push(part);
|
|
99
|
+
}
|
|
100
|
+
if (!affirm.length || !negate.length) return undefined;
|
|
101
|
+
return [
|
|
102
|
+
{ polarity: 'affirm', text: affirm.join('; ') },
|
|
103
|
+
{ polarity: 'negate', text: negate.join('; ') },
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What the clause is ABOUT: a backticked or bracketed element name is the one distinctive token. */
|
|
108
|
+
function elementRefs(text: string): string[] {
|
|
109
|
+
return [...new Set(
|
|
110
|
+
[...text.matchAll(/`([^`]+)`|\[([^\]]+)\]/g)]
|
|
111
|
+
.map((m) => (m[1] ?? m[2]).trim().toLowerCase())
|
|
112
|
+
.filter((x) => x.length >= 2 && x.length <= 60),
|
|
113
|
+
)];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Absence, in sungen's own assertion grammar (`is hidden`, `does not exist`) plus the prose a spec
|
|
118
|
+
* restatement uses. Positive visibility has no marker — `User see [T]` never says "is visible" —
|
|
119
|
+
* so the affirm side is "an assertion that is not a denial", which is what the suite actually writes.
|
|
120
|
+
*/
|
|
121
|
+
const NEGATIVE_ASSERTION = /\bis hidden\b|\bare hidden\b|\bnot (?:visible|shown|displayed|present)\b|\bdoes not (?:exist|show|appear|render)\b|\bnot exists?\b|\bno longer\b|表示されない|không hiển thị/i;
|
|
122
|
+
|
|
123
|
+
/** Assertion lines of a scenario block — Then plus the And/But that inherit its bucket. */
|
|
124
|
+
function assertionLines(block: string): string[] {
|
|
125
|
+
const out: string[] = [];
|
|
126
|
+
let bucket = '';
|
|
127
|
+
for (const raw of block.split('\n')) {
|
|
128
|
+
const kw = raw.trim().match(/^(given|when|then|and|but)\s+/);
|
|
129
|
+
if (!kw) continue;
|
|
130
|
+
if (kw[1] !== 'and' && kw[1] !== 'but') bucket = kw[1];
|
|
131
|
+
if (bucket === 'then') out.push(raw.trim());
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Does this scenario block assert THIS direction of the requirement? */
|
|
137
|
+
function directionCovered(block: string, dir: FrDirection, subject: string[]): boolean {
|
|
138
|
+
const lines = assertionLines(block);
|
|
139
|
+
const pool = lines.length ? lines : [block];
|
|
140
|
+
// Narrow to the lines about the clause's element when the suite names it the same way the spec
|
|
141
|
+
// does; a spec in one language and selector keys in another must not read as "no evidence".
|
|
142
|
+
const about = subject.length ? pool.filter((l) => subject.some((x) => l.includes(x))) : [];
|
|
143
|
+
const scan = about.length ? about : pool;
|
|
144
|
+
return scan.some((l) => NEGATIVE_ASSERTION.test(l) === (dir.polarity === 'negate'));
|
|
145
|
+
}
|
|
146
|
+
|
|
58
147
|
function specTriggersIn(text: string): string[] {
|
|
59
148
|
return SPEC_TRIGGER.filter((t) => t.re.test(text)).map((t) => t.trigger);
|
|
60
149
|
}
|
|
@@ -86,7 +175,10 @@ export function parseSpecClauses(specPath: string): { frs: FrClause[]; valRows:
|
|
|
86
175
|
const id = m[1].toUpperCase();
|
|
87
176
|
if (seen.has(id)) continue; // first declaration wins
|
|
88
177
|
seen.add(id);
|
|
89
|
-
|
|
178
|
+
const text = m[2].replace(/\*\*/g, '').trim();
|
|
179
|
+
const modality = modalityOf(text);
|
|
180
|
+
// A MUST/MUST NOT pair is TWO requirements wearing one id (#679) — carry both directions.
|
|
181
|
+
frs.push({ id, text, modality, directions: modality === 'MUST' ? splitDirections(text) : undefined });
|
|
90
182
|
}
|
|
91
183
|
|
|
92
184
|
// Validation Rules table: a row carries a Constraint, a Trigger cell, and (often) a code.
|
|
@@ -156,33 +248,51 @@ export function citedIds(featureText: string): Set<string> {
|
|
|
156
248
|
export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featureText: string): SpecCoverageResult {
|
|
157
249
|
const { frs, valRows } = parseSpecClauses(specPath);
|
|
158
250
|
if (!fs.existsSync(specPath) || (frs.length === 0 && valRows.length === 0)) {
|
|
159
|
-
return { hasSpec: fs.existsSync(specPath), frTotal: 0, frCovered: 0, uncoveredMust: [], inferredOnly: [], triggerGaps: [], verdict: 'pass' };
|
|
251
|
+
return { hasSpec: fs.existsSync(specPath), frTotal: 0, frCovered: 0, uncoveredMust: [], inferredOnly: [], triggerGaps: [], clauseGaps: [], verdict: 'pass' };
|
|
160
252
|
}
|
|
161
253
|
const featLower = featureText.toLowerCase();
|
|
162
254
|
const cites = citedIds(featureText);
|
|
255
|
+
const blocks = scenarioBlocks(featureText);
|
|
163
256
|
|
|
164
257
|
// FR coverage: explicit @spec:FR / literal FR-id citation, else keyword fallback.
|
|
165
258
|
const uncoveredMust: { id: string; text: string }[] = [];
|
|
259
|
+
const clauseGaps: ClauseGap[] = [];
|
|
166
260
|
const inferredOnly: string[] = [];
|
|
261
|
+
let frTotal = 0;
|
|
167
262
|
let frCovered = 0;
|
|
168
263
|
for (const fr of frs) {
|
|
264
|
+
// A requirement stating both directions is TWO units — counting it as one is what let a suite
|
|
265
|
+
// holding only the MUST half read 100% covered (#679).
|
|
266
|
+
const units = fr.directions ? fr.directions.length : 1;
|
|
267
|
+
frTotal += units;
|
|
169
268
|
const idLower = fr.id.toLowerCase();
|
|
170
269
|
const cited = featLower.includes(idLower) || cites.has(fr.id.toUpperCase());
|
|
171
270
|
const words = [...new Set((fr.text.toLowerCase().match(/[a-z][a-z-]{4,}/g) || []))]
|
|
172
271
|
.filter((w) => !/must|should|system|screen|users?|value|input|field/.test(w));
|
|
173
272
|
const kwHit = words.length > 0 && scenarios.some((s) => words.filter((w) => s.haystack.includes(w)).length >= Math.min(2, words.length));
|
|
174
|
-
if (cited
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
273
|
+
if (!cited && !kwHit) {
|
|
274
|
+
if (fr.modality === 'MUST') uncoveredMust.push({ id: fr.id, text: fr.text.slice(0, 90) });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!cited) inferredOnly.push(fr.id); // covered, but nothing links it explicitly
|
|
278
|
+
if (!fr.directions) { frCovered++; continue; }
|
|
279
|
+
// Which scenarios speak for this requirement — by its id (tags and comments sit in the block)
|
|
280
|
+
// or by the same keyword rule used above.
|
|
281
|
+
const owning = blocks.filter((b) => b.includes(idLower)
|
|
282
|
+
|| (words.length > 0 && words.filter((w) => b.includes(w)).length >= Math.min(2, words.length)));
|
|
283
|
+
// Cited from outside any scenario (a header comment): nothing to judge a direction against, so
|
|
284
|
+
// report nothing rather than invent a gap.
|
|
285
|
+
if (!owning.length) { frCovered += units; continue; }
|
|
286
|
+
const subject = elementRefs(fr.text);
|
|
287
|
+
for (const dir of fr.directions) {
|
|
288
|
+
if (owning.some((b) => directionCovered(b, dir, subject))) frCovered++;
|
|
289
|
+
else if (fr.modality === 'MUST') clauseGaps.push({ id: fr.id, polarity: dir.polarity, text: dir.text.slice(0, 90) });
|
|
179
290
|
}
|
|
180
291
|
}
|
|
181
292
|
|
|
182
293
|
// Per-constraint trigger coverage — the matrix-collapse catch.
|
|
183
294
|
// generic words that don't identify a constraint (would over-match happy-path scenarios)
|
|
184
295
|
const GENERIC = new Set(['valid', 'local', 'part', 'domain', 'email', 'address', 'must', 'exist', 'store', 'field', 'input', 'value', 'formed', 'well', 'character', 'characters']);
|
|
185
|
-
const blocks = scenarioBlocks(featureText);
|
|
186
296
|
const triggerGaps: TriggerGap[] = [];
|
|
187
297
|
for (const row of valRows) {
|
|
188
298
|
const kw = (row.constraint.toLowerCase().match(/[a-z]{4,}/g) || []).filter((w) => !GENERIC.has(w));
|
|
@@ -198,9 +308,9 @@ export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featur
|
|
|
198
308
|
}
|
|
199
309
|
|
|
200
310
|
const verdict: SpecCoverageResult['verdict'] =
|
|
201
|
-
uncoveredMust.length > 0 || triggerGaps.length > 0 ? 'fail' : 'pass';
|
|
311
|
+
uncoveredMust.length > 0 || triggerGaps.length > 0 || clauseGaps.length > 0 ? 'fail' : 'pass';
|
|
202
312
|
|
|
203
|
-
return { hasSpec: true, frTotal
|
|
313
|
+
return { hasSpec: true, frTotal, frCovered, uncoveredMust, inferredOnly, triggerGaps, clauseGaps, verdict };
|
|
204
314
|
}
|
|
205
315
|
|
|
206
316
|
/**
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appium environment bundle — emit `appium-environment/` into a mobile project and run its entry point.
|
|
3
|
+
*
|
|
4
|
+
* The bundle (bash for macOS, PowerShell for Windows) provisions the OS layer BELOW Appium: JDK, Android
|
|
5
|
+
* SDK + env vars, Xcode CLT, applesimutils, then the project-local Appium platform drivers, and ends with
|
|
6
|
+
* `appium driver doctor`. Emitted by `sungen init --mobile` / `sungen capability add mobile` and run once
|
|
7
|
+
* right after the driver install (spec: docs/spec/sungen_mobile_environment_setup_spec.md, issue #718).
|
|
8
|
+
*
|
|
9
|
+
* Core ships the scripts as templates only — no runtime is embedded, nothing is installed globally.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
|
|
15
|
+
export const APPIUM_ENV_DIR = 'appium-environment';
|
|
16
|
+
|
|
17
|
+
export interface EmitResult {
|
|
18
|
+
created: string[];
|
|
19
|
+
skipped: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type EnvSetupOutcome = 'ran' | 'failed' | 'skipped' | 'unsupported';
|
|
23
|
+
|
|
24
|
+
/** Bundled template root — resolves from src/orchestrator (tsx) and dist/orchestrator (copy-templates). */
|
|
25
|
+
const TEMPLATE_ROOT = path.join(__dirname, 'templates', APPIUM_ENV_DIR);
|
|
26
|
+
|
|
27
|
+
/** Files under `dir`, relative and posix-separated, sorted for a deterministic emit order. */
|
|
28
|
+
function listTemplateFiles(dir: string, prefix = ''): string[] {
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
33
|
+
if (entry.isDirectory()) out.push(...listTemplateFiles(path.join(dir, entry.name), rel));
|
|
34
|
+
else out.push(rel);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Copy the bundle into `<cwd>/appium-environment/`, skipping every file that already exists (a QA may
|
|
41
|
+
* have edited the pinned cmdline-tools build or a hint — same idempotency as the other emitted files).
|
|
42
|
+
* `*.sh` get the executable bit explicitly: npm and git do not reliably preserve it.
|
|
43
|
+
*/
|
|
44
|
+
export function emitAppiumEnvironment(cwd: string): EmitResult {
|
|
45
|
+
const created: string[] = [];
|
|
46
|
+
const skipped: string[] = [];
|
|
47
|
+
// Non-fatal like the other provisioning steps: a broken install (templates missing) must not abort
|
|
48
|
+
// `init` / `capability add` — the harness scaffold is still valid without the bundle.
|
|
49
|
+
try {
|
|
50
|
+
for (const rel of listTemplateFiles(TEMPLATE_ROOT)) {
|
|
51
|
+
const dest = path.join(cwd, APPIUM_ENV_DIR, rel);
|
|
52
|
+
const label = `${APPIUM_ENV_DIR}/${rel}`;
|
|
53
|
+
if (fs.existsSync(dest)) { skipped.push(label); continue; }
|
|
54
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
55
|
+
fs.copyFileSync(path.join(TEMPLATE_ROOT, rel), dest);
|
|
56
|
+
if (rel.endsWith('.sh')) fs.chmodSync(dest, 0o755);
|
|
57
|
+
created.push(label);
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.warn(`⚠️ Could not emit ${APPIUM_ENV_DIR}/ (${e instanceof Error ? e.message : e}) — reinstall @sun-asterisk/sungen and re-run: sungen capability add mobile`);
|
|
61
|
+
}
|
|
62
|
+
return { created, skipped };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The command a QA re-runs by hand, for hints and the init summary. */
|
|
66
|
+
export function appiumEnvironmentRerunCommand(): string {
|
|
67
|
+
return process.platform === 'win32'
|
|
68
|
+
? `powershell -NoProfile -ExecutionPolicy Bypass -File .\\${APPIUM_ENV_DIR}\\setup.ps1`
|
|
69
|
+
: `./${APPIUM_ENV_DIR}/setup.sh`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Translate the bundle's POSIX flags to their PowerShell switches. `sungen setup-mobile-env` speaks
|
|
74
|
+
* one flag vocabulary on both OSes, so the user does not have to know which script is underneath.
|
|
75
|
+
*/
|
|
76
|
+
const PS_SWITCH: Record<string, string> = {
|
|
77
|
+
'--check': '-Check',
|
|
78
|
+
'--skip-android': '-SkipAndroid',
|
|
79
|
+
'--no-profile': '-NoProfile',
|
|
80
|
+
// `--skip-ios` has no Windows counterpart: setup.ps1 has no iOS steps to skip.
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function entryCommand(cwd: string, flags: string[] = []): { file: string; args: string[] } | null {
|
|
84
|
+
const bundle = path.join(cwd, APPIUM_ENV_DIR);
|
|
85
|
+
if (process.platform === 'win32') {
|
|
86
|
+
const switches = flags.map(f => PS_SWITCH[f]).filter((s): s is string => !!s);
|
|
87
|
+
return {
|
|
88
|
+
file: 'powershell',
|
|
89
|
+
// -NoProfile here is powershell.exe's own "skip the user profile" flag, unrelated to the
|
|
90
|
+
// bundle's -NoProfile switch; the latter arrives through `switches` after -File.
|
|
91
|
+
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', path.join(bundle, 'setup.ps1'), ...switches],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// setup.sh handles Darwin; on Linux it prints the manual-path notice and exits 0.
|
|
95
|
+
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
96
|
+
return { file: 'bash', args: [path.join(bundle, 'setup.sh'), ...flags] };
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run the emitted entry point for this OS with inherited stdio — its per-step output IS the UX, and any
|
|
103
|
+
* Homebrew / sudo prompt must reach the user. Non-fatal by contract: problems are reported with the re-run
|
|
104
|
+
* command and init continues, because the scaffold itself is already complete.
|
|
105
|
+
*/
|
|
106
|
+
export function runAppiumEnvironmentSetup(
|
|
107
|
+
cwd: string,
|
|
108
|
+
opts: { skip?: boolean; flags?: string[] } = {},
|
|
109
|
+
): EnvSetupOutcome {
|
|
110
|
+
if (opts.skip) {
|
|
111
|
+
console.log(`– skipped environment setup (--skip-env-setup) — run it later: sungen setup-mobile-env`);
|
|
112
|
+
return 'skipped';
|
|
113
|
+
}
|
|
114
|
+
const cmd = entryCommand(cwd, opts.flags);
|
|
115
|
+
if (!cmd) {
|
|
116
|
+
console.log(`ℹ️ environment setup ships for macOS (setup.sh) and Windows (setup.ps1) — on ${process.platform} follow ${APPIUM_ENV_DIR}/README.md → Manual fallback`);
|
|
117
|
+
return 'unsupported';
|
|
118
|
+
}
|
|
119
|
+
console.log(`\n🔧 Provisioning the Appium environment (${APPIUM_ENV_DIR}/) — idempotent, re-run any time...`);
|
|
120
|
+
const result = spawnSync(cmd.file, cmd.args, { cwd, stdio: 'inherit' });
|
|
121
|
+
if (result.status === 0) return 'ran';
|
|
122
|
+
// status null = the shell itself could not start (ENOENT) or was killed — name the real cause.
|
|
123
|
+
const exit = result.error ? `could not start ${cmd.file}: ${result.error.message}`
|
|
124
|
+
: result.status === null ? `signal ${result.signal ?? '?'}` : `exit ${result.status}`;
|
|
125
|
+
console.warn(`⚠️ environment setup reported problems (${exit}) — fix the ✗ items above, then re-run: sungen setup-mobile-env (or ${appiumEnvironmentRerunCommand()} directly)`);
|
|
126
|
+
return 'failed';
|
|
127
|
+
}
|
|
@@ -22,6 +22,8 @@ export interface MobileInitOptions {
|
|
|
22
22
|
iosBundleId?: string;
|
|
23
23
|
iosDevice?: string;
|
|
24
24
|
iosVersion?: string;
|
|
25
|
+
/** Emit `appium-environment/` but do not run it now (`--skip-env-setup`); QA re-runs it by hand. */
|
|
26
|
+
skipEnvSetup?: boolean;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export interface ScaffoldResult {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
writeMobileCapabilities,
|
|
17
17
|
installMobileDriver,
|
|
18
18
|
} from './mobile-runtime-scaffolder';
|
|
19
|
+
import { emitAppiumEnvironment, runAppiumEnvironmentSetup, appiumEnvironmentRerunCommand } from './appium-environment-setup';
|
|
19
20
|
|
|
20
21
|
export { MobileInitOptions };
|
|
21
22
|
|
|
@@ -99,7 +100,7 @@ export class ProjectInitializer {
|
|
|
99
100
|
private force: boolean;
|
|
100
101
|
private mobile: MobileInitOptions;
|
|
101
102
|
|
|
102
|
-
constructor(private baseUrl: string, opts: { agents?: AdapterKey[]; force?: boolean; enabled?: boolean; mobileOnly?: boolean; appPackage?: string; appActivity?: string; appApk?: string; androidUdid?: string; iosBundleId?: string; iosDevice?: string; iosVersion?: string } = {}) {
|
|
103
|
+
constructor(private baseUrl: string, opts: { agents?: AdapterKey[]; force?: boolean; enabled?: boolean; mobileOnly?: boolean; appPackage?: string; appActivity?: string; appApk?: string; androidUdid?: string; iosBundleId?: string; iosDevice?: string; iosVersion?: string; skipEnvSetup?: boolean } = {}) {
|
|
103
104
|
this.baseCwd = process.cwd();
|
|
104
105
|
this.cwd = this.baseCwd;
|
|
105
106
|
this.agents = opts.agents && opts.agents.length ? opts.agents : (['claude'] as AdapterKey[]);
|
|
@@ -114,6 +115,8 @@ export class ProjectInitializer {
|
|
|
114
115
|
iosBundleId: opts.iosBundleId,
|
|
115
116
|
iosDevice: opts.iosDevice,
|
|
116
117
|
iosVersion: opts.iosVersion,
|
|
118
|
+
// `--skip-env-setup`: emit appium-environment/ but do not run it (CI, or the manual path).
|
|
119
|
+
skipEnvSetup: opts.skipEnvSetup ?? false,
|
|
117
120
|
};
|
|
118
121
|
}
|
|
119
122
|
|
|
@@ -500,6 +503,8 @@ export class ProjectInitializer {
|
|
|
500
503
|
console.log(` ${stepIndex++}. cd ${projectName}`);
|
|
501
504
|
}
|
|
502
505
|
if (this.mobileOnly) {
|
|
506
|
+
console.log(` ${stepIndex++}. Open a NEW terminal if the environment setup wrote your shell profile (PATH / JAVA_HOME / ANDROID_HOME)`);
|
|
507
|
+
console.log(` ${stepIndex++}. Environment not ready, or a new machine? Re-run: ${appiumEnvironmentRerunCommand()} (idempotent)`);
|
|
503
508
|
console.log(` ${stepIndex++}. Fill in .env.appium with your device/app details (see .env.appium.example)`);
|
|
504
509
|
console.log(` ${stepIndex++}. Create your first screen: sungen add --screen <name>`);
|
|
505
510
|
console.log(` ${stepIndex++}. Use AI (Copilot/Claude) to generate Gherkin + selectors from the live app`);
|
|
@@ -642,8 +647,17 @@ export class ProjectInitializer {
|
|
|
642
647
|
this.createdItems.push(...result.created);
|
|
643
648
|
this.skippedItems.push(...result.skipped);
|
|
644
649
|
|
|
650
|
+
// appium-environment/ provisioning bundle — emitted BEFORE the driver install so it lands even when
|
|
651
|
+
// npm is offline, run AFTER it so its Appium step finds the project-local CLI. One summary line, not 17.
|
|
652
|
+
const env = emitAppiumEnvironment(this.cwd);
|
|
653
|
+
if (env.created.length > 0) this.createdItems.push(`appium-environment/ (${env.created.length} files)`);
|
|
654
|
+
else this.skippedItems.push('appium-environment/');
|
|
655
|
+
|
|
645
656
|
// Best-effort driver install — non-fatal so offline init still scaffolds.
|
|
646
657
|
installMobileDriver(this.cwd);
|
|
658
|
+
|
|
659
|
+
// Provision JDK / Android SDK / Xcode CLT / env vars / platform drivers — non-fatal by contract.
|
|
660
|
+
runAppiumEnvironmentSetup(this.cwd, { skip: this.mobile.skipEnvSetup });
|
|
647
661
|
}
|
|
648
662
|
|
|
649
663
|
/**
|
|
@@ -124,8 +124,12 @@ needs any of these, it is a **finding for QA** — surface it in the run summary
|
|
|
124
124
|
| `SG-E027` | A selectors.yaml entry declares a **`type:` that is not a selector type** — `type: 'testId'` (capital I), `'xpth'`, `'rol'`, `'test-id'`. YAML is untyped at run time, so this used to compile clean and resolve through the strategy switch's `default:` as `getByPlaceholder('<the value>')`: a locator that cannot match, surfacing much later as a timeout naming placeholder text nobody wrote. Reported once per entry, naming the entry key and the nearest valid spelling; every step using that entry now compiles to a `throw` | Fix the spelling in selectors.yaml. Valid types: `placeholder`, `role`, `testid`, `label`, `text`, `locator`, `id`, `upload`, `page`, `column`, `frame`, `table`, and the mobile strategies `accessibility-id`, `xpath`, `android-uiautomator`, `ios-predicate`, `css`. This is a selectors.yaml-only fix — allowed inside the run-test auto-fix loop |
|
|
125
125
|
| `SG-W030` | A selectors.yaml **`expanded.class`/`expanded.attribute`** value carries a character a class or attribute name cannot — whitespace, a quote, a backslash, `$`, `{`, `}`. The value is pasted into the generated spec (string literal, regex, `classList` argument) and selectors.yaml is often generated from the page under test, so it is refused and the field dropped: the step falls back to driving and asserting `aria-expanded`. Reported once per entry and field. The checked/selected signal is refused outright instead of warned about, because dropping it leaves the step with no state to read at all | Use the plain class or attribute name (Tailwind arbitrary values such as `bg-[#eff6ff]` or `!mt-0` are accepted); never paste a selector fragment or quoted text into the field |
|
|
126
126
|
| `SG-E031` | A selectors.yaml **`checked:`/`selected:` declaration the compiler refuses**: the two spellings disagree (`checked.class` and `selected.class` with different values), a class token or attribute name carries whitespace / a quote / a backslash, or one entry declares both a `class` and an `attribute`. Every one of them is silent at run time — the generated matcher tests a single token, so the step would simply time out blaming your component — and an entry declaring two signals lets an action drive one while the assertion after it reads the other. Refused, not dropped: with no signal the step is back on the *"has no on/off state to set"* failure the declaration exists to remove | Declare ONE spelling and ONE of `class` / `attribute`, as a single plain token: `checked: { class: 'is-checked' }`, `selected: { class: 'border-link' }`, or `checked: { attribute: 'data-checked' }`. This is a selectors.yaml-only fix, allowed inside the run-test auto-fix loop |
|
|
127
|
+
| `SG-W032` | A step carries the row filter **`in [T] table with {{v}}`** but the pattern that compiled it dropped the clause, so the generated code runs against the WHOLE table. `Then User see [Status] text in [Job Table] table with {{job_title}}` asserts on every `.status` in the table and passes on a row the sentence never named. The `table-*` family (positioned rows included) resolves its own container and is not flagged, nor is the two-`with` form, which scopes the row correctly, nor a step that WRITES — a write needs a value, so its `with {{v}}` IS that value and `fill [Search] field in [T] table with {{v}}` is an ordinary container-scoped write | Rewrite with the two-`with` form: `User see [Status] text with <expected> in [Job Row] row with {{job_title}}`. Point the second `[…]` at a ROW entry in selectors.yaml, not at the table entry — a filter applied to the table narrows nothing |
|
|
128
|
+
| `SG-W034` | A `see [X] column with {{v}}` step runs inside a table whose rows are declared with a **`rows:` shape**, so the row is not an ARIA row and holds no ARIA cells — but the assertion's only other way to reach a cell is `getByRole('cell')`, which matches nothing there. `columns.<col>.index` cannot rescue it: it indexes that same empty set. Before this the step failed as a timeout blaming the app. Not reported on a real `<table>`, where the role fallback is correct | Name the cell on the column: `columns: { title: { index: 0, header: 'Title', elements: { cell: { type: locator, value: '.job-title' } } } }`. This is a selectors.yaml-only fix — allowed inside the run-test auto-fix loop |
|
|
129
|
+
| `SG-E035` | **`table match data:` on a table declared with a `rows:` shape** and no matching `columns:` entries. The assertion reads its header row from `thead tr` and addresses cells as `td, th`; a <div> list has neither — forever, not as a render race — so the step could only wait out every timeout and fail on an empty header row. Refused at compile time, naming the columns it needs | Declare each DataTable column on the table entry with the selector that matches its cell inside a row: `columns: { title: { index: 0, header: 'Title', elements: { cell: { type: locator, value: '.job-title' } } } }`. Declaring columns also removes the runtime header read on a real `<table>`. This is a selectors.yaml-only fix — allowed inside the run-test auto-fix loop |
|
|
130
|
+
| `SG-W036` | A **`remember [X] … as {{v}}` capture through a `locator`/`css` selector with no `nth:`**. A capture reads ONE element and the step takes the FIRST match — the right default, and what the documented `nth: 0` workaround asked for after Playwright's strict mode turned a list-shaped selector into a hard failure naming the count. But the first of many is a decision: if the selector describes every item in a list, the captured value is the first row's. Not reported for testid/label/placeholder/role+name/text entries, which name a particular thing | Declare the index (`nth: 0`) to say the first one is meant, or narrow the selector to the single element. This is a selectors.yaml-only fix — allowed inside the run-test auto-fix loop |
|
|
127
131
|
|
|
128
|
-
A diagnostic never changes generation's own exit code — `sungen generate --strict` turns any collected diagnostic into a non-zero exit for CI gates. They are not all inert, though: **`SG-W001`, `SG-W011`, `SG-E020`, `SG-E021`, `SG-W020`, `SG-E023`, `SG-E024`, `SG-E025`, `SG-E026` and `SG-
|
|
132
|
+
A diagnostic never changes generation's own exit code — `sungen generate --strict` turns any collected diagnostic into a non-zero exit for CI gates. They are not all inert, though: **`SG-W001`, `SG-W011`, `SG-E020`, `SG-E021`, `SG-W020`, `SG-E023`, `SG-E024`, `SG-E025`, `SG-E026`, `SG-E027` and `SG-E035` additionally compile the offending step to a runtime `throw`, and `SG-E031` refuses the whole file** — a step that reads as a check and silently checks nothing is worse than one that fails loudly. Generation always COMPLETES: the rest of the scenario and the rest of the file still compile. `SG-E022` is the odd one out — it blames the compiler, not your `.feature`, and throws nothing (the file is written so you can inspect it).
|
|
129
133
|
|
|
130
134
|
### Runtime error → `Test data "<key>" references ${QA_*} but the environment variable is not set`
|
|
131
135
|
|