@cspeach/cli 0.9.0 → 1.1.0
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/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/smoke/freestyle-spec.ts
|
|
2
|
+
//
|
|
3
|
+
// Track 3 Task 5 — freestyle render-smoke SPEC DERIVATION.
|
|
4
|
+
//
|
|
5
|
+
// The freestyle composer emits a plain webapp/ tree (views, i18n, manifest) with
|
|
6
|
+
// NO backend @UI annotations to lean on (that is what makes it "freestyle"). To
|
|
7
|
+
// make its output render-smoke-provable we DERIVE a Track-2 SmokeSpec from the
|
|
8
|
+
// app's own files:
|
|
9
|
+
//
|
|
10
|
+
// webapp/view/<boot view>.view.xml → primary control → controlKind
|
|
11
|
+
// → <Column> header i18n keys → expectedColumns (table only)
|
|
12
|
+
// → id-bearing press="..." handlers → press exercises
|
|
13
|
+
// webapp/i18n/i18n.properties → resolves the column keys to rendered texts
|
|
14
|
+
// webapp/manifest.json → routing.routes[].name/pattern → route exercises
|
|
15
|
+
//
|
|
16
|
+
// It is a TOLERANT sax-level scan (regex), NOT a strict XML parser: the repo has
|
|
17
|
+
// no XML-parser dependency and must not gain one (Tech Stack decision). A real
|
|
18
|
+
// UI5 view is well-formed enough that a header-anchored regex is reliable, and a
|
|
19
|
+
// tolerant scan degrades gracefully (an odd tag becomes 'custom', a stray key
|
|
20
|
+
// becomes a warning) instead of throwing on the first namespace quirk.
|
|
21
|
+
//
|
|
22
|
+
// LIVE-LEG CORRECTNESS (review fixes):
|
|
23
|
+
// * BOOT-VIEW SCOPING — controlKind / expectedColumns / press exercises are
|
|
24
|
+
// scoped to the boot view ONLY (the initial route's target viewName, else the
|
|
25
|
+
// manifest rootView). Only the boot view is in the DOM at smoke time; merging a
|
|
26
|
+
// Detail view's columns would false-red checkColumns on any multi-view app.
|
|
27
|
+
// If the boot view can't be resolved we fall back to scanning all views AND
|
|
28
|
+
// record a warning. Route exercises always cover ALL routes.
|
|
29
|
+
// * REAL PRESS SELECTORS — a press exercise's selectorHint is a DOM-id suffix
|
|
30
|
+
// selector (`[id$="--<id>"]`, UI5 prefixes control ids with the view id), NOT
|
|
31
|
+
// the controller handler name (which no DOM element carries). A press element
|
|
32
|
+
// with no id degrades to a warning, not a guaranteed-red exercise.
|
|
33
|
+
// * PATTERN-AWARE ROUTES — parameterized route patterns (`{id}` / `:id:`) are
|
|
34
|
+
// skipped with a warning (we won't fabricate segment values); the driver
|
|
35
|
+
// navigates by route NAME via the UI5 router (navTo), which UI5 maps to the
|
|
36
|
+
// pattern.
|
|
37
|
+
//
|
|
38
|
+
// The returned value IS a Track-2 SmokeSpec (assignable straight into runSmoke,
|
|
39
|
+
// no widening of SmokeSpec — cycle-1 A-I2) plus a `warnings` array carrying
|
|
40
|
+
// non-fatal derivation diagnostics. runSmoke ignores the extra field; a
|
|
41
|
+
// caller/report surfaces it.
|
|
42
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
/**
|
|
45
|
+
* Thrown when there is nothing to render-smoke — no webapp/view dir, or a view
|
|
46
|
+
* dir with no *.view.xml files. Typed so the caller can distinguish "not a
|
|
47
|
+
* freestyle app / empty app" from a real derivation and skip cleanly.
|
|
48
|
+
*/
|
|
49
|
+
export class FreestyleSmokeDerivationError extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'FreestyleSmokeDerivationError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// The local preview URL a freestyle app serves under `npm run start` / `fiori run`
|
|
56
|
+
// (see scaffold.ts + fiori_render_smoke). The app files carry no URL, so the
|
|
57
|
+
// derived spec seeds the conventional preview URL; a caller may override it.
|
|
58
|
+
const DEFAULT_PREVIEW_URL = 'http://localhost:8080/index.html';
|
|
59
|
+
/** Strip XML comments so a commented-out `<Table>`/`press=` can't flip a derivation. */
|
|
60
|
+
function stripXmlComments(xml) {
|
|
61
|
+
return xml.replace(/<!--[\s\S]*?-->/g, '');
|
|
62
|
+
}
|
|
63
|
+
/** Parse a .properties body into a key→value map (mirrors i18n.ts: ignores comments/blanks). */
|
|
64
|
+
function parseProperties(text) {
|
|
65
|
+
const map = new Map();
|
|
66
|
+
for (const line of text.split(/\r?\n/)) {
|
|
67
|
+
const t = line.trim();
|
|
68
|
+
if (!t || t.startsWith('#') || t.startsWith('!'))
|
|
69
|
+
continue;
|
|
70
|
+
const eq = t.indexOf('=');
|
|
71
|
+
if (eq === -1)
|
|
72
|
+
continue;
|
|
73
|
+
map.set(t.slice(0, eq).trim(), t.slice(eq + 1).trim());
|
|
74
|
+
}
|
|
75
|
+
return map;
|
|
76
|
+
}
|
|
77
|
+
/** True if the view source contains a tag whose local name matches `local`. */
|
|
78
|
+
function hasTag(xml, local) {
|
|
79
|
+
// Match `<Table`, `<mvc:Table`, `<t:Table` — optional namespace prefix, then a
|
|
80
|
+
// word boundary so `Table` doesn't match `TableSelectDialog`.
|
|
81
|
+
return new RegExp(`<(?:[\\w.]+:)?${local}(?=[\\s/>])`).test(xml);
|
|
82
|
+
}
|
|
83
|
+
/** Primary-control precedence: Table→table, VizFrame→chart, Form/SimpleForm→form, else custom. */
|
|
84
|
+
function deriveControlKind(xml) {
|
|
85
|
+
if (hasTag(xml, 'Table'))
|
|
86
|
+
return 'table';
|
|
87
|
+
if (hasTag(xml, 'VizFrame'))
|
|
88
|
+
return 'chart';
|
|
89
|
+
if (hasTag(xml, 'SimpleForm') || hasTag(xml, 'Form'))
|
|
90
|
+
return 'form';
|
|
91
|
+
return 'custom';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Extract the i18n KEY of each table column header. For every `<Column>…</Column>`
|
|
95
|
+
* block, take the first `{i18n>key}` binding (the header Text). Self-closing
|
|
96
|
+
* columns and columns without an i18n binding are skipped.
|
|
97
|
+
*/
|
|
98
|
+
function extractColumnKeys(xml) {
|
|
99
|
+
const keys = [];
|
|
100
|
+
const columnRe = /<(?:[\w.]+:)?Column\b[^>]*>([\s\S]*?)<\/(?:[\w.]+:)?Column>/g;
|
|
101
|
+
const i18nRe = /\{i18n>([^}]+)\}/;
|
|
102
|
+
let m;
|
|
103
|
+
while ((m = columnRe.exec(xml)) !== null) {
|
|
104
|
+
const hit = i18nRe.exec(m[1]);
|
|
105
|
+
if (hit)
|
|
106
|
+
keys.push(hit[1].trim());
|
|
107
|
+
}
|
|
108
|
+
return keys;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Yield each element's opening tag (`<…>`) in order. A hand scanner rather than a
|
|
112
|
+
* regex because attribute VALUES contain `>` (e.g. an `{i18n>key}` binding), so a
|
|
113
|
+
* naive `<[^>]*>` would split a tag mid-way. Quotes are tracked so only an
|
|
114
|
+
* unquoted `>` closes the tag. Comments are assumed already stripped.
|
|
115
|
+
*/
|
|
116
|
+
function* iterStartTags(xml) {
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < xml.length) {
|
|
119
|
+
if (xml[i] !== '<') {
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
let j = i + 1;
|
|
124
|
+
let inQuote = false;
|
|
125
|
+
while (j < xml.length) {
|
|
126
|
+
const c = xml[j];
|
|
127
|
+
if (c === '"')
|
|
128
|
+
inQuote = !inQuote;
|
|
129
|
+
else if (c === '>' && !inQuote)
|
|
130
|
+
break;
|
|
131
|
+
j++;
|
|
132
|
+
}
|
|
133
|
+
yield xml.slice(i, j + 1);
|
|
134
|
+
i = j + 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Find every element that carries a simple `press="…"` handler, in view order,
|
|
139
|
+
* de-duplicated by handler. For each, capture its `id` attribute (if any) so the
|
|
140
|
+
* caller can build a REAL DOM selector. Data-binding press expressions
|
|
141
|
+
* (`press="{= …}"`) are ignored — only a method-name handler is considered.
|
|
142
|
+
*/
|
|
143
|
+
function extractPressElements(xml) {
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const out = [];
|
|
146
|
+
const pressRe = /\bpress\s*=\s*"(\.?[A-Za-z_$][\w$]*)"/;
|
|
147
|
+
const idRe = /\bid\s*=\s*"([^"]+)"/;
|
|
148
|
+
for (const tag of iterStartTags(xml)) {
|
|
149
|
+
const pressHit = pressRe.exec(tag);
|
|
150
|
+
if (!pressHit)
|
|
151
|
+
continue;
|
|
152
|
+
const handler = pressHit[1];
|
|
153
|
+
if (seen.has(handler))
|
|
154
|
+
continue;
|
|
155
|
+
seen.add(handler);
|
|
156
|
+
out.push({ handler, id: idRe.exec(tag)?.[1] });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/** Read manifest.json routing.routes[] (name + pattern) in order. Tolerant of a missing/malformed manifest. */
|
|
161
|
+
function extractRoutes(manifest) {
|
|
162
|
+
const routes = manifest?.['sap.ui5']?.routing?.routes;
|
|
163
|
+
if (!Array.isArray(routes))
|
|
164
|
+
return [];
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const r of routes) {
|
|
167
|
+
const name = r?.name;
|
|
168
|
+
if (typeof name === 'string' && name.length > 0) {
|
|
169
|
+
out.push({ name, pattern: typeof r?.pattern === 'string' ? r.pattern : '' });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/** A UI5 pattern is parameterized (has mandatory `{id}` or optional `:id:` segments) → not navigable blind. */
|
|
175
|
+
function isParameterizedPattern(pattern) {
|
|
176
|
+
return /[{:]/.test(pattern);
|
|
177
|
+
}
|
|
178
|
+
/** A UI5 viewName (`zcs.app.view.Main`) → the view file basename (`Main.view.xml`). */
|
|
179
|
+
function viewFileForName(viewName) {
|
|
180
|
+
const base = viewName.includes('.') ? viewName.slice(viewName.lastIndexOf('.') + 1) : viewName;
|
|
181
|
+
return `${base}.view.xml`;
|
|
182
|
+
}
|
|
183
|
+
/** Extract a viewName from a manifest node that may be a string or `{ viewName }` object. */
|
|
184
|
+
function viewNameOf(node) {
|
|
185
|
+
if (typeof node === 'string')
|
|
186
|
+
return node;
|
|
187
|
+
if (node && typeof node === 'object' && typeof node.viewName === 'string') {
|
|
188
|
+
return node.viewName;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the BOOT view's file from the manifest: the initial route's target
|
|
194
|
+
* viewName FIRST (the route with an empty pattern, else the first route), then the
|
|
195
|
+
* manifest rootView as a fallback. Returns the matching filename from `viewFiles`,
|
|
196
|
+
* or undefined when it can't be resolved (caller falls back to scanning all views + a warning).
|
|
197
|
+
*
|
|
198
|
+
* Precedence rationale: the common freestyle template layout is a rootView App
|
|
199
|
+
* SHELL (a plain sap.m.App with no table/columns) that hosts a routed content view
|
|
200
|
+
* (Main). Preferring rootView would scope the smoke to the empty shell — controlKind
|
|
201
|
+
* 'custom', no columns, no press — silently weakening it. The routed target is what
|
|
202
|
+
* actually renders at boot, so it wins; rootView is only the fallback for a
|
|
203
|
+
* shell-less single-view app whose route target can't be resolved.
|
|
204
|
+
*/
|
|
205
|
+
function resolveBootViewFile(manifest, viewFiles) {
|
|
206
|
+
const ui5 = manifest?.['sap.ui5'];
|
|
207
|
+
if (!ui5)
|
|
208
|
+
return undefined;
|
|
209
|
+
const candidates = [];
|
|
210
|
+
const routing = ui5.routing;
|
|
211
|
+
if (routing && Array.isArray(routing.routes)) {
|
|
212
|
+
const initial = routing.routes.find((r) => r?.pattern === '') ?? routing.routes[0];
|
|
213
|
+
let targetKey = initial?.target;
|
|
214
|
+
if (Array.isArray(targetKey))
|
|
215
|
+
targetKey = targetKey[0];
|
|
216
|
+
const target = targetKey != null ? routing.targets?.[targetKey] : undefined;
|
|
217
|
+
candidates.push(viewNameOf(target?.viewName ?? target));
|
|
218
|
+
}
|
|
219
|
+
candidates.push(viewNameOf(ui5.rootView));
|
|
220
|
+
for (const viewName of candidates) {
|
|
221
|
+
if (!viewName)
|
|
222
|
+
continue;
|
|
223
|
+
const file = viewFileForName(viewName).toLowerCase();
|
|
224
|
+
const match = viewFiles.find((f) => f.toLowerCase() === file);
|
|
225
|
+
if (match)
|
|
226
|
+
return match;
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Derive a render-smoke SmokeSpec from a freestyle app's own webapp/ tree.
|
|
232
|
+
*
|
|
233
|
+
* @throws FreestyleSmokeDerivationError when there is nothing to assert (no view
|
|
234
|
+
* dir, or a view dir with no *.view.xml) — the caller skips cleanly.
|
|
235
|
+
*/
|
|
236
|
+
export function deriveFreestyleSmokeSpec(appDir) {
|
|
237
|
+
const viewDir = join(appDir, 'webapp', 'view');
|
|
238
|
+
if (!existsSync(viewDir)) {
|
|
239
|
+
throw new FreestyleSmokeDerivationError('nothing to assert — is this a freestyle app? (no webapp/view directory found)');
|
|
240
|
+
}
|
|
241
|
+
const viewFiles = readdirSync(viewDir)
|
|
242
|
+
.filter((f) => f.toLowerCase().endsWith('.view.xml'))
|
|
243
|
+
.sort();
|
|
244
|
+
if (viewFiles.length === 0) {
|
|
245
|
+
throw new FreestyleSmokeDerivationError('nothing to assert — is this a freestyle app? (webapp/view has no *.view.xml files)');
|
|
246
|
+
}
|
|
247
|
+
const warnings = [];
|
|
248
|
+
// Read the manifest once (best-effort) — drives boot-view + route derivation.
|
|
249
|
+
const manifestPath = join(appDir, 'webapp', 'manifest.json');
|
|
250
|
+
let manifest = undefined;
|
|
251
|
+
if (existsSync(manifestPath)) {
|
|
252
|
+
try {
|
|
253
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
manifest = undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// BOOT-VIEW SCOPING: only the boot view is in the DOM at smoke time. Scope
|
|
260
|
+
// controlKind / columns / press to it; fall back to all views + a warning.
|
|
261
|
+
const bootFile = resolveBootViewFile(manifest, viewFiles);
|
|
262
|
+
const scopedFiles = bootFile ? [bootFile] : viewFiles;
|
|
263
|
+
if (!bootFile && viewFiles.length > 1) {
|
|
264
|
+
warnings.push('could not resolve the boot view from the manifest (rootView / initial route target); ' +
|
|
265
|
+
'scanning ALL views for controlKind/columns/press — expected columns may include non-boot views');
|
|
266
|
+
}
|
|
267
|
+
const xml = stripXmlComments(scopedFiles.map((f) => readFileSync(join(viewDir, f), 'utf8')).join('\n'));
|
|
268
|
+
const controlKind = deriveControlKind(xml);
|
|
269
|
+
// i18n map (best-effort; an absent bundle just means every key is unresolved).
|
|
270
|
+
const i18nPath = join(appDir, 'webapp', 'i18n', 'i18n.properties');
|
|
271
|
+
const i18n = existsSync(i18nPath)
|
|
272
|
+
? parseProperties(readFileSync(i18nPath, 'utf8'))
|
|
273
|
+
: new Map();
|
|
274
|
+
// Columns only for a table control (rows-bearing list); resolve each key,
|
|
275
|
+
// falling back to the raw key + a warning when the bundle has no entry.
|
|
276
|
+
let expectedColumns;
|
|
277
|
+
if (controlKind === 'table') {
|
|
278
|
+
const cols = extractColumnKeys(xml).map((key) => {
|
|
279
|
+
const resolved = i18n.get(key);
|
|
280
|
+
if (resolved === undefined) {
|
|
281
|
+
warnings.push(`i18n key "${key}" not found in webapp/i18n/i18n.properties; using the raw key as the column expectation`);
|
|
282
|
+
return key;
|
|
283
|
+
}
|
|
284
|
+
return resolved;
|
|
285
|
+
});
|
|
286
|
+
if (cols.length > 0)
|
|
287
|
+
expectedColumns = cols;
|
|
288
|
+
}
|
|
289
|
+
// Press exercises (boot view): a real DOM-id-suffix selector per id-bearing
|
|
290
|
+
// handler; a press with no id degrades to a warning (never a guaranteed-red check).
|
|
291
|
+
const exercises = [];
|
|
292
|
+
for (const p of extractPressElements(xml)) {
|
|
293
|
+
if (!p.id) {
|
|
294
|
+
warnings.push(`press handler "${p.handler}" is on an element with no id; skipping its exercise ` +
|
|
295
|
+
'(cannot derive a stable DOM selector)');
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
exercises.push({ kind: 'press', selectorHint: `[id$="--${p.id}"]` });
|
|
299
|
+
}
|
|
300
|
+
// Route exercises (ALL routes): navigable (non-parameterized) patterns only;
|
|
301
|
+
// parameterized routes are skipped with a warning rather than fabricating values.
|
|
302
|
+
for (const route of extractRoutes(manifest)) {
|
|
303
|
+
if (isParameterizedPattern(route.pattern)) {
|
|
304
|
+
warnings.push(`route "${route.name}" has a parameterized pattern "${route.pattern}"; skipping its exercise ` +
|
|
305
|
+
'(cannot fabricate path parameters)');
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
exercises.push({ kind: 'route', name: route.name });
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
appUrl: DEFAULT_PREVIEW_URL,
|
|
312
|
+
controlKind,
|
|
313
|
+
...(expectedColumns ? { expectedColumns } : {}),
|
|
314
|
+
...(exercises.length > 0 ? { exercises } : {}),
|
|
315
|
+
warnings,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/smoke/run-smoke.ts
|
|
2
|
+
//
|
|
3
|
+
// Render-smoke, part B — the orchestrator: browser → driver → assertions.
|
|
4
|
+
//
|
|
5
|
+
// CONTRACT (spec T2-4): the render smoke is SKIPPABLE BUT NEVER SILENTLY GREEN.
|
|
6
|
+
// It returns a real content-asserting `verification:'smoke'` result ONLY when an
|
|
7
|
+
// authenticated app actually rendered. In every precondition-not-met case it
|
|
8
|
+
// returns `verification:'manual', passed:false` with a single `skipped` check
|
|
9
|
+
// that explains why — it can NEVER report a passing smoke without a real render.
|
|
10
|
+
//
|
|
11
|
+
// The four skip paths:
|
|
12
|
+
// 1. render_smoke config is OFF.
|
|
13
|
+
// 2. no Chromium-based browser was found (resolveBrowserExecutable → undefined).
|
|
14
|
+
// 3. the LOCAL preview URL is unreachable (F-1: `npm run start` isn't up).
|
|
15
|
+
// 4. the loaded page is a SAP LOGON form (F-1: the deployed BSP / an
|
|
16
|
+
// unauthenticated preview) — detected via the current URL and/or the logon
|
|
17
|
+
// selector, and treated as UNREACHABLE, never a passed boot.
|
|
18
|
+
//
|
|
19
|
+
// The browser + driver are injectable (RunSmokeDeps) so the unit suite drives
|
|
20
|
+
// every path with fakes and NEVER launches a real browser.
|
|
21
|
+
import { resolveBrowserExecutable } from './browser.js';
|
|
22
|
+
import { openApp as realOpenApp } from './driver.js';
|
|
23
|
+
import { checkBoot, checkOData, checkColumns, checkRows, checkExercise, } from './assertions.js';
|
|
24
|
+
/**
|
|
25
|
+
* UI5 selectors used by the driver-side collection. ONE exported const so Track
|
|
26
|
+
* 3 can extend it (add chart/form control selectors) without hunting through
|
|
27
|
+
* run-smoke's logic.
|
|
28
|
+
*/
|
|
29
|
+
export const SMOKE_SELECTORS = {
|
|
30
|
+
/** Responsive-table + grid-table column headers. */
|
|
31
|
+
tableHeaders: '.sapMColumnHeader, [role="columnheader"]',
|
|
32
|
+
/** Responsive-table + grid-table data rows. */
|
|
33
|
+
tableRows: '.sapMListTbl tr.sapMListTblRow, .sapUiTableRow',
|
|
34
|
+
/** Overview-page cards. */
|
|
35
|
+
ovpCards: '.sapOvpCard',
|
|
36
|
+
/** Freestyle chart control (sap.viz VizFrame) — Track 3 chart controlKind. */
|
|
37
|
+
vizFrame: '.sapVizFrame, .sapChart',
|
|
38
|
+
/** Freestyle form control (sap.ui.layout.form Form / SimpleForm) — Track 3 form controlKind. */
|
|
39
|
+
form: '.sapUiForm',
|
|
40
|
+
/** SAP logon-form fields (classic ICF login, SAML2 form, Fiori launchpad login). */
|
|
41
|
+
logon: '#sap-user, input[name="sap-user"], input[name="j_username"], #USERNAME_FIELD-inner, .sapUiUfdLoginPage',
|
|
42
|
+
};
|
|
43
|
+
// A current-URL that matches this is a logon-via-redirect: the preview bounced
|
|
44
|
+
// us to an auth endpoint instead of serving the app.
|
|
45
|
+
const LOGON_URL = /sap-system-login|\/saml2\/|\/logon|\/login/i;
|
|
46
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
47
|
+
/** A not-run result: manual verification, a single explanatory skipped check. */
|
|
48
|
+
function skipped(detail) {
|
|
49
|
+
return { verification: 'manual', passed: false, checks: [{ name: 'skipped', ok: false, detail }] };
|
|
50
|
+
}
|
|
51
|
+
/** Only a rows-bearing list control (table) gets a row-count assertion. */
|
|
52
|
+
function isRowsControl(kind) {
|
|
53
|
+
return kind === 'table';
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Execute each exercise through the page abstraction, capturing per-exercise
|
|
57
|
+
* outcomes for `checkExercise`. Around every interaction we snapshot the driver's
|
|
58
|
+
* console-error collection and keep only the errors that appeared DURING it — that
|
|
59
|
+
* is the concrete meaning of "new console errors" (dynamic A4). A press/route that
|
|
60
|
+
* throws is recorded as `threw`; a page that cannot drive the exercise (the seam is
|
|
61
|
+
* absent) is recorded as a throw too, so a missing capability is never silently green.
|
|
62
|
+
*/
|
|
63
|
+
async function runExercises(page, exercises) {
|
|
64
|
+
const results = [];
|
|
65
|
+
for (const ex of exercises) {
|
|
66
|
+
const before = page.consoleErrors().length;
|
|
67
|
+
const label = ex.kind === 'press' ? ex.selectorHint : ex.name;
|
|
68
|
+
let threw;
|
|
69
|
+
try {
|
|
70
|
+
if (ex.kind === 'press') {
|
|
71
|
+
if (!page.press)
|
|
72
|
+
throw new Error('driver cannot press (no press capability)');
|
|
73
|
+
await page.press(ex.selectorHint);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
if (!page.gotoRoute)
|
|
77
|
+
throw new Error('driver cannot navigate routes (no gotoRoute capability)');
|
|
78
|
+
await page.gotoRoute(ex.name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
threw = err instanceof Error ? err.message : String(err);
|
|
83
|
+
}
|
|
84
|
+
const newConsoleErrors = page.consoleErrors().slice(before);
|
|
85
|
+
results.push({ kind: ex.kind, label, threw, newConsoleErrors });
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Run the render smoke. See the module header for the skippable-never-green
|
|
91
|
+
* contract and the four skip paths.
|
|
92
|
+
*/
|
|
93
|
+
export async function runSmoke(spec, deps) {
|
|
94
|
+
// Skip path 1 — config off.
|
|
95
|
+
if (!deps.renderSmokeEnabled) {
|
|
96
|
+
return skipped('render smoke disabled (render_smoke config is off); no app was rendered');
|
|
97
|
+
}
|
|
98
|
+
const resolveBrowser = deps.resolveBrowser ?? (() => resolveBrowserExecutable());
|
|
99
|
+
const open = deps.openApp ?? realOpenApp;
|
|
100
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
101
|
+
// Skip path 2 — no browser.
|
|
102
|
+
const browser = resolveBrowser();
|
|
103
|
+
if (!browser) {
|
|
104
|
+
return skipped('no Chromium-based browser found (set CSPEACH_BROWSER to an Edge/Chrome executable); no app was rendered');
|
|
105
|
+
}
|
|
106
|
+
// Skip path 3 — preview server unreachable (openApp throws on nav failure).
|
|
107
|
+
let page;
|
|
108
|
+
try {
|
|
109
|
+
page = await open(browser.path, spec.appUrl, timeoutMs);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return skipped(`preview server not reachable at ${spec.appUrl}; run npm run start first`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
// Skip path 4 — SAP logon page (deployed BSP / unauthenticated preview).
|
|
116
|
+
// Detect via a logon-redirect URL OR the presence of a logon-form field, and
|
|
117
|
+
// treat it as unreachable — a logon page is NOT a booted app.
|
|
118
|
+
const url = page.currentUrl();
|
|
119
|
+
const logonHits = await page.queryTexts(SMOKE_SELECTORS.logon);
|
|
120
|
+
if (LOGON_URL.test(url) || logonHits.length > 0) {
|
|
121
|
+
return skipped(`preview served a SAP logon page (not authenticated) at ${url}; run npm run start first`);
|
|
122
|
+
}
|
|
123
|
+
// Real render — assert content. boot + odata always; columns only when the
|
|
124
|
+
// spec asked for them; rows only for a rows-bearing (table) control.
|
|
125
|
+
const checks = [];
|
|
126
|
+
checks.push(checkBoot(page.consoleErrors()));
|
|
127
|
+
checks.push(checkOData(page.failedRequests()));
|
|
128
|
+
if (spec.expectedColumns && spec.expectedColumns.length > 0) {
|
|
129
|
+
const headers = await page.queryTexts(SMOKE_SELECTORS.tableHeaders);
|
|
130
|
+
checks.push(checkColumns(headers, spec.expectedColumns));
|
|
131
|
+
}
|
|
132
|
+
if (isRowsControl(spec.controlKind)) {
|
|
133
|
+
const rows = await page.queryTexts(SMOKE_SELECTORS.tableRows);
|
|
134
|
+
checks.push(checkRows(rows.length, spec.allowEmptyRows === true));
|
|
135
|
+
}
|
|
136
|
+
// Track 3 (dynamic A4): run each declared exercise through the SAME page
|
|
137
|
+
// abstraction, diffing the driver's console-error collection around each
|
|
138
|
+
// interaction. A press that throws or an exercise that emits a new console
|
|
139
|
+
// error fails the smoke — it can never be silently green.
|
|
140
|
+
if (spec.exercises && spec.exercises.length > 0) {
|
|
141
|
+
const results = await runExercises(page, spec.exercises);
|
|
142
|
+
checks.push(checkExercise(results));
|
|
143
|
+
}
|
|
144
|
+
return { verification: 'smoke', passed: checks.every((c) => c.ok), checks };
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await page.close();
|
|
148
|
+
}
|
|
149
|
+
}
|