@cspeach/cli 1.0.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.
Files changed (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +59 -66
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -0,0 +1,248 @@
1
+ // cspeach-cli/src/tools/fiori/samples/loader.ts
2
+ //
3
+ // Source loader for the fiori freestyle composer's grounding corpus (Track 3).
4
+ //
5
+ // Two paths, one interface:
6
+ // * VENDORED entries — sources are bundled at build time in the imported
7
+ // SAMPLE_SOURCES map (data/sources.generated.ts). We read them straight
8
+ // from memory: no fetch, no cache, no filesystem. NEVER read from a
9
+ // data/sources/ dir and NEVER readFileSync a shipped source (shipping
10
+ // rule A-C1 — the map is the only source of truth for vendored sources).
11
+ // * UN-VENDORED entries — sources are fetched lazily over the network via an
12
+ // injected `fetchText`, cached under ~/.cspeach/sample-cache/<name>/, and
13
+ // served from that cache on every subsequent load (zero further fetches).
14
+ //
15
+ // Un-vendored entries carry NO `files` list in index.json (only vendored ones
16
+ // do). The authoritative file list is the sample's own manifest.json, which
17
+ // every UI5 demokit sample publishes at `sap.ui5.config.sample.files`. So the
18
+ // un-vendored path fetches manifest.json first, reads that list, then fetches
19
+ // each declared file. If the sample can't be fetched (offline, 404, malformed
20
+ // manifest), we throw a typed SampleFetchError whose message tells the model to
21
+ // author from the fiori recipes catalog + sap-docs instead — sample grounding
22
+ // is an optional boost and must never block composition.
23
+ import { homedir } from 'node:os';
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join, resolve, sep } from 'node:path';
26
+ import { SAMPLE_SOURCES } from './data/sources.generated.js';
27
+ /**
28
+ * Raised when an entry name — or a manifest-declared file path — would escape
29
+ * the cache directory (a `..` segment or an absolute path). A security
30
+ * boundary: refused before any fetch or filesystem write.
31
+ */
32
+ export class UnsafeSampleNameError extends Error {
33
+ sampleName;
34
+ constructor(sampleName, reason = `contains a ".." path segment`) {
35
+ super(`Refusing to load sample path "${sampleName}": ${reason} — ` +
36
+ `it could escape the sample cache directory.`);
37
+ this.sampleName = sampleName;
38
+ this.name = 'UnsafeSampleNameError';
39
+ }
40
+ }
41
+ /**
42
+ * Raised when an un-vendored sample cannot be retrieved (offline, HTTP error,
43
+ * or a manifest without a usable file list). The message is deliberately
44
+ * actionable for the model: fall back to authoring from recipes + sap-docs.
45
+ * Sample grounding is optional and must never block composition.
46
+ */
47
+ export class SampleFetchError extends Error {
48
+ sampleName;
49
+ reason;
50
+ cause;
51
+ constructor(sampleName, reason, cause) {
52
+ super(`Could not retrieve UI5 sample "${sampleName}" (${reason}). ` +
53
+ `Author the UI from the fiori recipes catalog and sap-docs instead — ` +
54
+ `sample grounding is optional and never blocks composition.`);
55
+ this.sampleName = sampleName;
56
+ this.reason = reason;
57
+ this.cause = cause;
58
+ this.name = 'SampleFetchError';
59
+ }
60
+ }
61
+ const CACHE_ROOT_DEFAULT = () => join(homedir(), '.cspeach', 'sample-cache');
62
+ /**
63
+ * Pinned raw-content root for lazy-fetching un-vendored samples.
64
+ *
65
+ * Index `sourceRef.rawBase` values are TARBALL-RELATIVE (e.g.
66
+ * `src/sap.f/test/sap/f/demokit/sample/AvatarGroup`) — Node `fetch` rejects a
67
+ * relative URL, so the loader prepends this absolute root before any network
68
+ * call. The tag (`1.120.14`) MUST match the corpus build tag pinned in
69
+ * scripts/build-sample-index.ts (`REF`); the sources were extracted from that
70
+ * exact OpenUI5 tree, so lazy-fetched files must come from the same ref.
71
+ */
72
+ export const RAW_CONTENT_ROOT = 'https://raw.githubusercontent.com/SAP/openui5/1.120.14/';
73
+ /**
74
+ * Resolve an index `rawBase` to an absolute fetch base (no trailing slash).
75
+ * Relative values get the pinned raw-content root prepended; already-absolute
76
+ * `http(s)://` values (future-proofing) pass through unchanged.
77
+ */
78
+ export function resolveRawBase(rawBase) {
79
+ const trimmed = rawBase.replace(/\/+$/, '');
80
+ if (/^https?:\/\//i.test(trimmed))
81
+ return trimmed;
82
+ return RAW_CONTENT_ROOT + trimmed.replace(/^\/+/, '');
83
+ }
84
+ /** Default network fetch — used only when no `fetchText` is injected. */
85
+ async function defaultFetchText(url) {
86
+ const res = await fetch(url);
87
+ if (!res.ok)
88
+ throw new Error(`HTTP ${res.status} for ${url}`);
89
+ return res.text();
90
+ }
91
+ /** Split a name/relative path into segments on either slash flavour. */
92
+ function segments(p) {
93
+ return p.split(/[\\/]+/).filter(Boolean);
94
+ }
95
+ /** Reject any name with a `..` (or `.`) traversal segment. */
96
+ function assertSafeName(name) {
97
+ const segs = segments(name);
98
+ if (segs.length === 0 || segs.some((s) => s === '..' || s === '.')) {
99
+ throw new UnsafeSampleNameError(name);
100
+ }
101
+ }
102
+ /**
103
+ * Join `rel` under `base`, guaranteeing the result stays inside `base`.
104
+ * Belt-and-braces against a `..` or an absolute path slipping in via a
105
+ * manifest-declared file path.
106
+ */
107
+ function safeJoin(base, rel) {
108
+ const target = resolve(base, rel);
109
+ const root = resolve(base);
110
+ if (target !== root && !target.startsWith(root + sep)) {
111
+ const reason = /^([a-zA-Z]:[\\/]|[\\/])/.test(rel)
112
+ ? 'is an absolute path'
113
+ : `contains a ".." path segment`;
114
+ throw new UnsafeSampleNameError(rel, reason);
115
+ }
116
+ return target;
117
+ }
118
+ /**
119
+ * The ordered, deduped file list for an un-vendored sample: manifest.json
120
+ * first, then the paths its manifest declares under sap.ui5.config.sample.files.
121
+ * Throws SampleFetchError if the manifest is unparseable or lists no files.
122
+ */
123
+ function fileListFromManifest(manifestText, name) {
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(manifestText);
127
+ }
128
+ catch (e) {
129
+ throw new SampleFetchError(name, 'manifest.json is not valid JSON', e);
130
+ }
131
+ const files = parsed?.['sap.ui5']?.config?.sample?.files;
132
+ if (!Array.isArray(files) || files.length === 0) {
133
+ throw new SampleFetchError(name, 'manifest.json declares no sap.ui5.config.sample.files list');
134
+ }
135
+ const seen = new Set(['manifest.json']);
136
+ const out = ['manifest.json'];
137
+ for (const f of files) {
138
+ if (typeof f !== 'string' || seen.has(f))
139
+ continue;
140
+ seen.add(f);
141
+ out.push(f);
142
+ }
143
+ return out;
144
+ }
145
+ /**
146
+ * Read a fully-cached sample back from disk, or null if not cached.
147
+ *
148
+ * ANY failure — missing file, a torn/corrupt cached manifest.json (invalid
149
+ * JSON or no `files` key), an unreadable file, an unsafe path — is treated as a
150
+ * cache miss (returns null) so the caller refetches and self-heals. A corrupt
151
+ * cache must never poison the sample with a permanent, misleading error.
152
+ */
153
+ function readFromCache(sampleDir, name) {
154
+ const manifestPath = join(sampleDir, 'manifest.json');
155
+ if (!existsSync(manifestPath))
156
+ return null;
157
+ try {
158
+ const manifestText = readFileSync(manifestPath, 'utf8');
159
+ const list = fileListFromManifest(manifestText, name);
160
+ const files = [];
161
+ for (const rel of list) {
162
+ const abs = safeJoin(sampleDir, rel);
163
+ if (!existsSync(abs))
164
+ return null; // partial cache → refetch
165
+ files.push({ path: rel, content: readFileSync(abs, 'utf8') });
166
+ }
167
+ return files;
168
+ }
169
+ catch {
170
+ return null; // corrupt/unreadable cache → refetch
171
+ }
172
+ }
173
+ /**
174
+ * Load a sample's source files.
175
+ *
176
+ * Vendored → from the bundled SAMPLE_SOURCES map (fromCache: false).
177
+ * Un-vendored → fetch-then-cache; second and later loads come from the on-disk
178
+ * cache with zero fetches (fromCache: true).
179
+ *
180
+ * @throws UnsafeSampleNameError if the entry name has a `..` traversal segment.
181
+ * @throws SampleFetchError if an un-vendored sample can't be fetched — with a
182
+ * message steering the caller to recipes + sap-docs.
183
+ */
184
+ export async function loadSampleSource(entry, deps) {
185
+ assertSafeName(entry.name);
186
+ // Vendored: read straight from the bundled map. No network, no cache.
187
+ if (entry.sourceRef.vendored) {
188
+ const bundle = SAMPLE_SOURCES[entry.name];
189
+ if (!bundle) {
190
+ throw new SampleFetchError(entry.name, 'marked vendored but absent from the bundled SAMPLE_SOURCES map');
191
+ }
192
+ const files = Object.keys(bundle)
193
+ .sort()
194
+ .map((path) => ({ path, content: bundle[path] }));
195
+ return { files, fromCache: false };
196
+ }
197
+ // Un-vendored: lazy fetch with an on-disk cache.
198
+ const cacheRoot = deps.cacheDir ?? CACHE_ROOT_DEFAULT();
199
+ const sampleDir = safeJoin(cacheRoot, entry.name);
200
+ const cached = readFromCache(sampleDir, entry.name);
201
+ if (cached)
202
+ return { files: cached, fromCache: true };
203
+ const rawBase = entry.sourceRef.rawBase;
204
+ if (!rawBase) {
205
+ throw new SampleFetchError(entry.name, 'un-vendored entry has no rawBase to fetch from');
206
+ }
207
+ const fetchText = deps.fetchText ?? defaultFetchText;
208
+ const base = resolveRawBase(rawBase);
209
+ let files;
210
+ try {
211
+ const manifestText = await fetchText(`${base}/manifest.json`);
212
+ const list = fileListFromManifest(manifestText, entry.name);
213
+ // Validate the WHOLE declared file list for containment up front, before
214
+ // any further fetch or write — an evil later path is refused here, not
215
+ // mid-persist after the network work is already done.
216
+ for (const rel of list)
217
+ safeJoin(sampleDir, rel);
218
+ files = [{ path: 'manifest.json', content: manifestText }];
219
+ for (const rel of list) {
220
+ if (rel === 'manifest.json')
221
+ continue;
222
+ const content = await fetchText(`${base}/${rel}`);
223
+ files.push({ path: rel, content });
224
+ }
225
+ }
226
+ catch (e) {
227
+ // A containment failure is a security refusal — surface it, don't mask it.
228
+ if (e instanceof UnsafeSampleNameError)
229
+ throw e;
230
+ if (e instanceof SampleFetchError)
231
+ throw e;
232
+ throw new SampleFetchError(entry.name, 'network fetch failed', e);
233
+ }
234
+ // Persist to cache. The cache is a best-effort optimization: if writing fails
235
+ // (EACCES, ENOTDIR, disk full) the fetched sources are already in memory, so
236
+ // we return them anyway rather than throw a raw fs error over a usable result.
237
+ try {
238
+ for (const f of files) {
239
+ const abs = safeJoin(sampleDir, f.path);
240
+ mkdirSync(dirname(abs), { recursive: true });
241
+ writeFileSync(abs, f.content, 'utf8');
242
+ }
243
+ }
244
+ catch {
245
+ /* cache write failed — sources are in memory; grounding proceeds uncached */
246
+ }
247
+ return { files, fromCache: false };
248
+ }
@@ -0,0 +1,63 @@
1
+ // cspeach-cli/src/tools/fiori/samples/search.ts
2
+ //
3
+ // Pure, deterministic keyword search over the vendored UI5 sample corpus
4
+ // (built in Task 1). No I/O, no globals — the index is passed in. Task 4
5
+ // wires this into the fiori tool; it stays trivially unit-testable here.
6
+ // Scoring weights. Chosen so the three signals never interfere:
7
+ // exact control match >> any number of keyword hits >> library boost.
8
+ const CONTROL_EXACT = 1000; // dominates everything
9
+ const KEYWORD_HIT = 10; // per distinct query token that overlaps the entry
10
+ const SAP_M_BOOST = 1; // < KEYWORD_HIT, so it only breaks keyword-count ties
11
+ const DEFAULT_MAX = 5;
12
+ /** Lowercase and split on any non-alphanumeric run; drop empties. */
13
+ function tokenize(text) {
14
+ return text
15
+ .toLowerCase()
16
+ .split(/[^a-z0-9]+/i)
17
+ .filter(Boolean);
18
+ }
19
+ /**
20
+ * Search the sample index.
21
+ *
22
+ * Scoring: an exact `control` match dominates; then case-insensitive token
23
+ * overlap between the query text and each entry's keywords + name +
24
+ * description; then a small library boost for `sap.m`. Entries with no signal
25
+ * at all are excluded (so an unknown query returns []). Ties break
26
+ * deterministically by entry name (ascending).
27
+ */
28
+ export function searchSamples(index, query) {
29
+ const queryTokens = [...new Set(tokenize(query.text ?? ''))];
30
+ const wantControl = query.control?.trim().toLowerCase();
31
+ const max = query.max ?? DEFAULT_MAX;
32
+ const hits = [];
33
+ for (const entry of index) {
34
+ let score = 0;
35
+ const controlExact = !!wantControl && entry.control.toLowerCase() === wantControl;
36
+ if (controlExact)
37
+ score += CONTROL_EXACT;
38
+ // Token overlap against keywords + name + description.
39
+ const bag = new Set(tokenize(`${entry.keywords.join(' ')} ${entry.name} ${entry.description}`));
40
+ let keywordHits = 0;
41
+ for (const t of queryTokens)
42
+ if (bag.has(t))
43
+ keywordHits += 1;
44
+ score += keywordHits * KEYWORD_HIT;
45
+ // Only entries with an actual signal are candidates — this is what makes
46
+ // an unknown query return empty rather than every sap.m entry.
47
+ if (!controlExact && keywordHits === 0)
48
+ continue;
49
+ if (entry.library === 'sap.m')
50
+ score += SAP_M_BOOST;
51
+ hits.push({ entry, score });
52
+ }
53
+ hits.sort((a, b) => {
54
+ if (b.score !== a.score)
55
+ return b.score - a.score;
56
+ // Locale-independent codepoint compare — a deterministic tie-break that
57
+ // does not vary with the host ICU locale/collation (CI vs dev machine).
58
+ const an = a.entry.name;
59
+ const bn = b.entry.name;
60
+ return an < bn ? -1 : an > bn ? 1 : 0;
61
+ });
62
+ return hits.slice(0, max);
63
+ }
@@ -0,0 +1,2 @@
1
+ // cspeach-cli/src/tools/fiori/samples/types.ts
2
+ export {};
@@ -0,0 +1,74 @@
1
+ // A failed HTTP response is only smoke-relevant when it is an OData call:
2
+ // $metadata (model load), $batch (V4 read/write), or an entity-set request
3
+ // under the gateway/odata4 path. An incidental non-OData 404 (favicon, a
4
+ // analytics beacon) must NOT false-fail an otherwise healthy render.
5
+ const ODATA_URL = /\$metadata|\$batch|\/sap\/opu\/odata|\/odata4?\//i;
6
+ /** Boot check: the app loaded without throwing anything to the console. */
7
+ export function checkBoot(consoleErrors) {
8
+ const ok = consoleErrors.length === 0;
9
+ return {
10
+ name: 'boot',
11
+ ok,
12
+ detail: ok
13
+ ? 'app booted with no console errors'
14
+ : `${consoleErrors.length} console error(s) during boot: ${consoleErrors.slice(0, 3).join(' | ')}`,
15
+ };
16
+ }
17
+ /** OData check: no 4xx/5xx on any $metadata / $batch / entity-set request. */
18
+ export function checkOData(failedRequests) {
19
+ const bad = failedRequests.filter((r) => ODATA_URL.test(r.url));
20
+ const ok = bad.length === 0;
21
+ return {
22
+ name: 'odata',
23
+ ok,
24
+ detail: ok
25
+ ? 'no failed OData requests'
26
+ : `${bad.length} failed OData request(s): ${bad.map((r) => `${r.status} ${r.url}`).join(' | ')}`,
27
+ };
28
+ }
29
+ /** Columns check: every expected column is present in the header texts (case-insensitive). */
30
+ export function checkColumns(headerTexts, expectedColumns) {
31
+ const present = new Set(headerTexts.map((h) => h.trim().toLowerCase()));
32
+ const missing = expectedColumns.filter((c) => !present.has(c.trim().toLowerCase()));
33
+ const ok = missing.length === 0;
34
+ return {
35
+ name: 'columns',
36
+ ok,
37
+ detail: ok
38
+ ? `all ${expectedColumns.length} expected column(s) present`
39
+ : `missing column(s): ${missing.join(', ')} (rendered: ${headerTexts.join(', ') || 'none'})`,
40
+ };
41
+ }
42
+ /** Rows check: 0 rows passes ONLY when the caller acknowledged an empty list. */
43
+ export function checkRows(rowCount, allowEmptyRows) {
44
+ if (rowCount > 0) {
45
+ return { name: 'rows', ok: true, detail: `${rowCount} row(s) rendered` };
46
+ }
47
+ if (allowEmptyRows) {
48
+ return { name: 'rows', ok: true, detail: 'empty list acknowledged' };
49
+ }
50
+ return { name: 'rows', ok: false, detail: '0 rows rendered and allowEmptyRows was not set' };
51
+ }
52
+ /**
53
+ * Exercise check: every exercise ran without throwing and without emitting a new
54
+ * console error. A single throwing handler or a single new console error fails the
55
+ * whole smoke (dynamic A4). Vacuously passes on an empty result set.
56
+ */
57
+ export function checkExercise(results) {
58
+ const failures = results.filter((r) => r.threw !== undefined || r.newConsoleErrors.length > 0);
59
+ const ok = failures.length === 0;
60
+ return {
61
+ name: 'exercises',
62
+ ok,
63
+ detail: ok
64
+ ? `all ${results.length} exercise(s) ran without new console errors`
65
+ : failures
66
+ .map((f) => {
67
+ const reason = f.threw !== undefined
68
+ ? `threw: ${f.threw}`
69
+ : `${f.newConsoleErrors.length} new console error(s): ${f.newConsoleErrors.slice(0, 2).join(' | ')}`;
70
+ return `${f.kind} ${f.label} — ${reason}`;
71
+ })
72
+ .join('; '),
73
+ };
74
+ }
@@ -0,0 +1,52 @@
1
+ // cspeach-cli/src/tools/fiori/smoke/browser.ts
2
+ //
3
+ // Render-smoke, part A — browser resolution.
4
+ //
5
+ // puppeteer-core does NOT download a browser; it drives a Chromium-based binary
6
+ // already installed on the machine (Edge on every Windows 11 box, or Chrome).
7
+ // This module finds that binary. It is a PURE function over an injectable
8
+ // existsSync + env so it unit-tests without touching the real filesystem — the
9
+ // production caller invokes it with no arguments (real fs.existsSync + real
10
+ // process.env).
11
+ //
12
+ // Task 9 consumes { path, kind }: `path` feeds puppeteer.launch({ executablePath }),
13
+ // `kind` is surfaced in the smoke report so the developer knows which engine ran.
14
+ import { existsSync as realExistsSync } from 'node:fs';
15
+ /** Classify an explicit override path by its filename — chrome vs edge. */
16
+ function classify(path) {
17
+ return /chrome/i.test(path) ? 'chrome' : 'edge';
18
+ }
19
+ /**
20
+ * Windows-first probe for a Chromium-based browser executable.
21
+ *
22
+ * Resolution order:
23
+ * 1. CSPEACH_BROWSER env override — explicit developer intent, returned as-is
24
+ * (not gated on existsSync; a bad path surfaces later at launch).
25
+ * 2. Microsoft Edge (ProgramFiles(x86) then ProgramFiles).
26
+ * 3. Google Chrome (ProgramFiles(x86) then ProgramFiles).
27
+ * 4. undefined when nothing is found.
28
+ *
29
+ * Pure over the injected deps; safe to call with none in production.
30
+ */
31
+ export function resolveBrowserExecutable(deps = {}) {
32
+ const existsSync = deps.existsSync ?? realExistsSync;
33
+ const env = deps.env ?? process.env;
34
+ const override = env.CSPEACH_BROWSER;
35
+ if (override) {
36
+ return { path: override, kind: classify(override) };
37
+ }
38
+ const programFiles = env.ProgramFiles;
39
+ const programFilesX86 = env['ProgramFiles(x86)'];
40
+ const candidates = [];
41
+ const push = (root, suffix, kind) => {
42
+ if (root)
43
+ candidates.push({ path: `${root}\\${suffix}`, kind });
44
+ };
45
+ // Edge before Chrome: Edge ships on every Windows 11 box, so it is the most
46
+ // reliable default; Chrome is the fallback.
47
+ push(programFilesX86, 'Microsoft\\Edge\\Application\\msedge.exe', 'edge');
48
+ push(programFiles, 'Microsoft\\Edge\\Application\\msedge.exe', 'edge');
49
+ push(programFilesX86, 'Google\\Chrome\\Application\\chrome.exe', 'chrome');
50
+ push(programFiles, 'Google\\Chrome\\Application\\chrome.exe', 'chrome');
51
+ return candidates.find((c) => existsSync(c.path));
52
+ }
@@ -0,0 +1,89 @@
1
+ // cspeach-cli/src/tools/fiori/smoke/driver.ts
2
+ //
3
+ // Render-smoke, part A — the page driver.
4
+ //
5
+ // This is a NEUTRAL url-driver. It launches a headless Chromium-based browser
6
+ // via puppeteer-core, navigates to whatever URL it is handed, and COLLECTS
7
+ // signals (failed HTTP responses, console errors) so the caller can reason
8
+ // about them. It makes NO assertions and bakes in NO assumption about what the
9
+ // URL is.
10
+ //
11
+ // F-1 (Critical, realized in Task 9): the smoke must target the LOCAL
12
+ // AUTHENTICATED PREVIEW (`npm run start` / `fiori run` → fiori-tools-proxy →
13
+ // real backend with the dev's auth), NOT the deployed BSP URL (which serves a
14
+ // SAP logon page and 401s every OData call — the "green tests, dead at the live
15
+ // leg" trap). This driver deliberately stays neutral: it exposes failedRequests()
16
+ // + consoleErrors() precisely so Task 9 can detect the logon-page / unreachable
17
+ // cases from the collected signals. Do not add any deployed-URL assumption here.
18
+ import puppeteer from 'puppeteer-core';
19
+ /**
20
+ * Launch a headless browser at `executablePath`, navigate to `url`, and return a
21
+ * SmokePage that has already begun collecting failed requests + console errors.
22
+ *
23
+ * `timeoutMs` bounds both the initial navigation and subsequent waitForSelector
24
+ * calls. Never called from unit tests (no real browser in CI) — exercised only
25
+ * on the live leg (VPN, local authenticated preview up).
26
+ */
27
+ export async function openApp(executablePath, url, timeoutMs) {
28
+ const browser = await puppeteer.launch({ executablePath, headless: true });
29
+ const failed = [];
30
+ const consoleErrs = [];
31
+ try {
32
+ const page = await browser.newPage();
33
+ // Collect BEFORE navigating so the initial document + its OData calls are seen.
34
+ page.on('response', (resp) => {
35
+ const status = resp.status();
36
+ if (status >= 400)
37
+ failed.push({ url: resp.url(), status });
38
+ });
39
+ page.on('console', (msg) => {
40
+ if (msg.type() === 'error')
41
+ consoleErrs.push(msg.text());
42
+ });
43
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: timeoutMs });
44
+ return {
45
+ currentUrl: () => page.url(),
46
+ waitForSelector: (selector) => page.waitForSelector(selector, { timeout: timeoutMs }),
47
+ queryTexts: (selector) => page.$$eval(selector, (els) => els.map((el) => (el.textContent ?? '').trim())),
48
+ failedRequests: () => [...failed],
49
+ consoleErrors: () => [...consoleErrs],
50
+ // Track 3 exercises (live leg only — never unit-tested; the suite injects
51
+ // fakes). `selectorHint` is a REAL DOM selector derived from the control id
52
+ // (`[id$="--<id>"]`), so a plain click drives the press handler.
53
+ press: async (selectorHint) => {
54
+ await page.click(selectorHint, { delay: 0 });
55
+ await page.waitForNetworkIdle({ idleTime: 200, timeout: timeoutMs }).catch(() => { });
56
+ },
57
+ // Navigate by route NAME via the UI5 router (navTo) — UI5 maps the name to
58
+ // its pattern, so this works even when the pattern is '' (root). Setting the
59
+ // hash by name would match nothing when name !== pattern.
60
+ gotoRoute: async (name) => {
61
+ await page.evaluate((route) => {
62
+ const w = window;
63
+ const Component = w.sap?.ui?.core?.Component;
64
+ const registry = Component?.registry?.all?.() ?? {};
65
+ for (const key of Object.keys(registry)) {
66
+ const comp = registry[key];
67
+ const router = typeof comp?.getRouter === 'function' ? comp.getRouter() : undefined;
68
+ if (router && typeof router.navTo === 'function') {
69
+ router.navTo(route);
70
+ return;
71
+ }
72
+ }
73
+ throw new Error(`no UI5 router found to navigate route "${route}"`);
74
+ }, name);
75
+ await page.waitForNetworkIdle({ idleTime: 200, timeout: timeoutMs }).catch(() => { });
76
+ },
77
+ close: async () => {
78
+ await browser.close();
79
+ },
80
+ };
81
+ }
82
+ catch (err) {
83
+ // Navigation failed (preview server down, timeout, ...). Don't leak the
84
+ // browser process — Task 9's precondition check turns this into a clean
85
+ // verification:'manual' with the caught reason.
86
+ await browser.close();
87
+ throw err;
88
+ }
89
+ }