@cspeach/cli 1.1.18 → 1.1.19

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 (224) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +58 -21
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
@@ -1,3 +1,4 @@
1
+ import { innerFailures } from './batch.js';
1
2
  // A failed HTTP response is only smoke-relevant when it is an OData call:
2
3
  // $metadata (model load), $batch (V4 read/write), or an entity-set request
3
4
  // under the gateway/odata4 path. An incidental non-OData 404 (favicon, a
@@ -8,34 +9,163 @@ const ODATA_URL = /\$metadata|\$batch|\/sap\/opu\/odata|\/odata4?\//i;
8
9
  // Dev-mode noise, not app defects (F-L2-12) — must not fail boot.
9
10
  const UNBUILT_DEV_404 = /(Component-preload\.js|resources\.json)/i;
10
11
  const LOAD_FAILURE = /(404|failed to load)/i;
11
- /** Boot check: the app loaded without throwing anything to the console. */
12
- export function checkBoot(consoleErrors) {
13
- const real = consoleErrors.filter((e) => !(UNBUILT_DEV_404.test(e) && LOAD_FAILURE.test(e)));
14
- const ok = real.length === 0;
12
+ // F-5 (L1-2b): 404s are judged by the NETWORK response URL, never by console
13
+ // text. Chrome's own "Failed to load resource" console line carries no URL, so
14
+ // an allowlist over console text matches nothing (a correct app fails) — the
15
+ // driver's failed-response list is the authority instead.
16
+ const CHROME_LOAD_FAILURE = /^Failed to load resource: the server responded with a status of \d+/;
17
+ /**
18
+ * The ONLY failed non-OData responses boot tolerates — each named, each a 404,
19
+ * each matched on the URL path. Anything else (a missing controller file, a
20
+ * missing BASE i18n bundle, any 5xx) fails boot. Never widen this to "all 404s".
21
+ */
22
+ export const BOOT_ALLOWED_404 = [
23
+ // UI5's ResourceBundle walks the locale chain (i18n_en_US → i18n_en → i18n)
24
+ // and 404s on every locale file an app does not ship. The base bundle
25
+ // (i18n.properties, no locale suffix) is NOT covered.
26
+ // Locale shape: a two-letter lower-case language, optionally _ + a two-letter
27
+ // upper-case region (de, pt_BR). `_abc` or `_EN` is a base bundle's name, not a locale.
28
+ { name: 'i18n locale fallback', path: /\/[^/]+_[a-z]{2}(_[A-Z]{2})?\.properties$/ },
29
+ // `fiori run` serves an unbuilt app: no preload bundle, no resources.json (F-L2-12).
30
+ { name: 'unbuilt dev serve', path: /\/(Component-preload\.js|resources\.json)$/ },
31
+ // The browser asks for /favicon.ico on its own; no app file references it.
32
+ { name: 'browser favicon probe', path: /^\/favicon\.ico$/ },
33
+ // The FLP sandbox bootstrap (sap/ushell sandbox.js) probes this optional
34
+ // config file at a fixed path and carries on without it.
35
+ { name: 'FLP sandbox optional config', path: /^\/appconfig\/fioriSandboxConfig\.json$/ },
36
+ ];
37
+ // UI5 logs future-deprecation notices (sync loading, bAsync:false) through
38
+ // Log.error, so they arrive as console errors. They are not failures of the app.
39
+ // Only UI5's own notice FORMS are matched — an error that merely contains the
40
+ // word "deprecated" ("TypeError: deprecated.call is not a function") still fails.
41
+ const UI5_DEPRECATION_FORMS = [
42
+ /\bis deprecated\. Please use\b/, // "Resolving a class in this fashion is deprecated. Please use …"
43
+ /\bshould always be called with bAsync:true\b/, // sap.ushell.Container.createRenderer()
44
+ /\bis created synchronously\. Synchronous instantiation is not allowed\b/, // sap/ushell renderer
45
+ // UI5 1.96 only: SAP's FE page templates set shortCutExists="true" on the Share
46
+ // macro (sap/fe/templates; gone by 1.108). Exactly this property — any other
47
+ // "ignored … not intended for public usage" still fails.
48
+ /\bProperty shortCutExists was ignored as it is not intended for public usage\b/,
49
+ // UI5 1.136: future-deprecation notice from sap.ui.mdc about SAP's own FE
50
+ // FilterBar / Table controls (their propertyInfo, not the app's).
51
+ /\[FUTURE FATAL\] PropertyInfo validation is disabled for control [^\s]+\. Migrate this control's propertyInfo\b/,
52
+ // UI5 1.136: FE's own table macro binds its context-menu Delete to command
53
+ // DeleteEntry::ContextMenu; SAP's app generator (fe writer 3.1.65) defines no
54
+ // commands, so every generated app logs this. Exactly this command.
55
+ /\bCommand 'DeleteEntry::ContextMenu' is not defined in component manifest\. No shortcut will be registered\b/,
56
+ ];
57
+ const UI5_DEPRECATION = { test: (e) => UI5_DEPRECATION_FORMS.some((re) => re.test(e)) };
58
+ function pathOf(url) {
59
+ try {
60
+ return new URL(url).pathname;
61
+ }
62
+ catch {
63
+ return url.split('?')[0] ?? url;
64
+ }
65
+ }
66
+ function isAllowed404(r) {
67
+ return r.status === 404 && BOOT_ALLOWED_404.some((a) => a.path.test(pathOf(r.url)));
68
+ }
69
+ // CAP phase 1 (rehearsal 2026-09-25): a CAP project's server answers the
70
+ // unbuilt preload's 404 with an HTML page and nosniff, so Chrome also logs that
71
+ // it refused to run it. Exactly this line, exactly Component-preload.js, and only
72
+ // when the network list shows that same URL answered an allowed 404 (F-5).
73
+ const REFUSED_PRELOAD = /^Refused to execute script from '([^']+\/Component-preload\.js)' because its MIME type \('text\/html'\) is not executable, and strict MIME type checking is enabled\.$/;
74
+ function isRefusedAllowedPreload(e, failedRequests) {
75
+ const url = REFUSED_PRELOAD.exec(e)?.[1];
76
+ return url !== undefined && failedRequests.some((r) => r.url === url && isAllowed404(r));
77
+ }
78
+ /**
79
+ * Boot check: no real console error, and no failed app-file request.
80
+ * `failedRequests` are the network responses >= 400; OData ones are left to the
81
+ * odata check (not counted twice).
82
+ */
83
+ // Dry run 2026-09-25: the header read "Suppliers & Quality Certificates"
84
+ // and every check was green. An HTML escape in the VISIBLE text is a defect.
85
+ const SHOWN_ESCAPE = /&(amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-fA-F]{1,6});/;
86
+ /** The first visible line carrying an HTML escape, or undefined. */
87
+ function escapedLine(visibleText) {
88
+ return visibleText.split(/\r?\n/).map((l) => l.trim()).find((l) => SHOWN_ESCAPE.test(l));
89
+ }
90
+ export function checkBoot(consoleErrors, failedRequests = [], visibleText) {
91
+ const real = consoleErrors.filter((e) => !CHROME_LOAD_FAILURE.test(e) &&
92
+ !UI5_DEPRECATION.test(e) &&
93
+ !(UNBUILT_DEV_404.test(e) && LOAD_FAILURE.test(e)) &&
94
+ !isRefusedAllowedPreload(e, failedRequests));
95
+ const badLoads = failedRequests.filter((r) => !ODATA_URL.test(r.url) && !isAllowed404(r));
96
+ const escaped = visibleText === undefined ? undefined : escapedLine(visibleText);
97
+ const ok = real.length === 0 && badLoads.length === 0 && escaped === undefined;
98
+ const parts = [];
99
+ if (escaped !== undefined)
100
+ parts.push(`HTML escape shown on screen: "${escaped.slice(0, 120)}"`);
101
+ if (real.length > 0)
102
+ parts.push(`${real.length} console error(s) during boot: ${real.slice(0, 3).join(' | ')}`);
103
+ if (badLoads.length > 0) {
104
+ parts.push(`${badLoads.length} failed app request(s): ${badLoads.slice(0, 5).map((r) => `${r.status} ${r.url}`).join(' | ')}`);
105
+ }
15
106
  return {
16
107
  name: 'boot',
17
108
  ok,
18
- detail: ok
19
- ? 'app booted with no console errors'
20
- : `${real.length} console error(s) during boot: ${real.slice(0, 3).join(' | ')}`,
109
+ detail: ok ? 'app booted with no console errors and no failed app requests' : parts.join('; '),
21
110
  };
22
111
  }
23
- /** OData check: no 4xx/5xx on any $metadata / $batch / entity-set request. */
24
- export function checkOData(failedRequests) {
112
+ /**
113
+ * OData check: no 4xx/5xx on any $metadata / $batch / entity-set request.
114
+ * F-2: FAILS when no OData request was observed at all — a check that judged
115
+ * zero requests proves nothing. `observed` is every response the driver saw
116
+ * (any status); `failedRequests` are the ones >= 400.
117
+ * L1-5a: `inner` are the requests INSIDE the recorded `$batch` bodies. A 200
118
+ * `$batch` whose Activate part is 400 fails here. When every inner part is fine
119
+ * the result is exactly what it was without them.
120
+ * CAP phase 1: `unanswered` are requests the browser sent that never got a
121
+ * response. With no OData response at all, unanswered OData requests are named —
122
+ * a server asking for a login (CAP's mocked auth) holds them open.
123
+ */
124
+ export function checkOData(failedRequests, observed, inner = [], unanswered = []) {
125
+ const seen = observed.filter((r) => ODATA_URL.test(r.url));
126
+ const held = unanswered.filter((u) => ODATA_URL.test(u));
127
+ if (seen.length === 0 && held.length > 0) {
128
+ return {
129
+ name: 'odata',
130
+ ok: false,
131
+ detail: `no OData response arrived — ${held.length} OData request(s) started but never answered: ${held.slice(0, 3).join(' | ')}. ` +
132
+ 'A server that asks for a login holds them open — for a CAP service with @requires, pass capUser.',
133
+ };
134
+ }
135
+ if (seen.length === 0) {
136
+ return {
137
+ name: 'odata',
138
+ ok: false,
139
+ detail: 'no OData request was observed (no $metadata, $batch or entity-set call) — nothing to judge',
140
+ };
141
+ }
25
142
  const bad = failedRequests.filter((r) => ODATA_URL.test(r.url));
26
- const ok = bad.length === 0;
143
+ const badInner = innerFailures(inner);
144
+ const ok = bad.length === 0 && badInner.length === 0;
145
+ const parts = [];
146
+ if (bad.length > 0)
147
+ parts.push(`${bad.length} failed OData request(s): ${bad.map((r) => `${r.status} ${r.url}`).join(' | ')}`);
148
+ if (badInner.length > 0) {
149
+ parts.push(`${badInner.length} failed inner $batch request(s): ` +
150
+ badInner
151
+ // The query ($select/$expand) is noise in a failure line; the path names the request.
152
+ .map((p) => `${p.status} ${p.method} ${p.url.split('?')[0]}${p.errorMessage ? ` — ${p.errorMessage}` : ''}`)
153
+ .join(' | '));
154
+ }
27
155
  return {
28
156
  name: 'odata',
29
157
  ok,
30
- detail: ok
31
- ? 'no failed OData requests'
32
- : `${bad.length} failed OData request(s): ${bad.map((r) => `${r.status} ${r.url}`).join(' | ')}`,
158
+ detail: ok ? `${seen.length} OData request(s) observed, none failed` : parts.join('; '),
33
159
  };
34
160
  }
161
+ /** F-8: UI5 hyphenates header text with U+00AD and may join words with U+00A0. */
162
+ export function normaliseHeader(text) {
163
+ return text.replace(/\u00ad/g, '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
164
+ }
35
165
  /** Columns check: every expected column is present in the header texts (case-insensitive). */
36
166
  export function checkColumns(headerTexts, expectedColumns) {
37
- const present = new Set(headerTexts.map((h) => h.trim().toLowerCase()));
38
- const missing = expectedColumns.filter((c) => !present.has(c.trim().toLowerCase()));
167
+ const present = new Set(headerTexts.map(normaliseHeader));
168
+ const missing = expectedColumns.filter((c) => !present.has(normaliseHeader(c)));
39
169
  const ok = missing.length === 0;
40
170
  return {
41
171
  name: 'columns',
@@ -56,24 +186,31 @@ export function checkRows(rowCount, allowEmptyRows) {
56
186
  return { name: 'rows', ok: false, detail: '0 rows rendered and allowEmptyRows was not set' };
57
187
  }
58
188
  /**
59
- * Exercise check: every exercise ran without throwing and without emitting a new
60
- * console error. A single throwing handler or a single new console error fails the
61
- * whole smoke (dynamic A4). Vacuously passes on an empty result set.
189
+ * Exercise check: every exercise ran without throwing, without a new console
190
+ * error and without an uncaught page error (F-6). Any one fails the whole smoke
191
+ * (dynamic A4). Vacuously passes on an empty result set.
62
192
  */
63
193
  export function checkExercise(results) {
64
- const failures = results.filter((r) => r.threw !== undefined || r.newConsoleErrors.length > 0);
194
+ const pageErrs = (r) => r.newPageErrors ?? [];
195
+ const failures = results.filter((r) => r.threw !== undefined || r.newConsoleErrors.length > 0 || pageErrs(r).length > 0);
65
196
  const ok = failures.length === 0;
66
197
  return {
67
198
  name: 'exercises',
68
199
  ok,
69
200
  detail: ok
70
- ? `all ${results.length} exercise(s) ran without new console errors`
201
+ ? `all ${results.length} exercise(s) ran without new console or page errors`
71
202
  : failures
72
203
  .map((f) => {
73
- const reason = f.threw !== undefined
74
- ? `threw: ${f.threw}`
75
- : `${f.newConsoleErrors.length} new console error(s): ${f.newConsoleErrors.slice(0, 2).join(' | ')}`;
76
- return `${f.kind} ${f.label} — ${reason}`;
204
+ const reasons = [];
205
+ if (f.threw !== undefined)
206
+ reasons.push(`threw: ${f.threw}`);
207
+ if (pageErrs(f).length > 0) {
208
+ reasons.push(`${pageErrs(f).length} uncaught page error(s): ${pageErrs(f).slice(0, 2).join(' | ')}`);
209
+ }
210
+ if (f.newConsoleErrors.length > 0) {
211
+ reasons.push(`${f.newConsoleErrors.length} new console error(s): ${f.newConsoleErrors.slice(0, 2).join(' | ')}`);
212
+ }
213
+ return `${f.kind} ${f.label} — ${reasons.join('; ')}`;
77
214
  })
78
215
  .join('; '),
79
216
  };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `UI.LineItem`, in order: every DataField-like entry that shows a property.
3
+ * Label = the DataField's own Label (it wins in Fiori), else the property's
4
+ * Common.Label. Action / annotation columns are excluded — they carry no
5
+ * property. An unlabelled column is KEPT with `label: undefined` so the
6
+ * `labels` check can fail on it instead of silently dropping it.
7
+ */
8
+ export function deriveExpectedColumns(audit) {
9
+ const byName = new Map(audit.properties.map((p) => [p.name, p]));
10
+ const out = [];
11
+ for (const col of [...audit.lineItem].sort((a, b) => a.position - b.position)) {
12
+ if (col.kind !== 'DataField' && col.kind !== 'DataFieldWithUrl')
13
+ continue;
14
+ if (!col.property)
15
+ continue;
16
+ const p = byName.get(col.property);
17
+ const common = p && p.labelSource !== 'none' ? p.label : undefined;
18
+ out.push({ property: col.property, label: col.label ?? common });
19
+ }
20
+ return out;
21
+ }
@@ -0,0 +1,273 @@
1
+ // cspeach-cli/src/tools/fiori/smoke/batch.ts
2
+ //
3
+ // L1-5a — a PURE parser for OData `$batch` bodies.
4
+ //
5
+ // Fiori Elements V4 sends every read and every draft action inside `$batch`.
6
+ // The OUTER response is 200 even when an inner PATCH or Activate is 400 / 412 /
7
+ // 428, so a smoke that judges outer statuses reads a failed activation as green
8
+ // (design §3.3, review F-4). This module turns a recorded request/response pair
9
+ // into one row per inner request: `{method, url, status, errorMessage}`.
10
+ //
11
+ // Both wire formats:
12
+ // - `multipart/mixed` (OData 4.01 Part 1 §11.7.7): parts are
13
+ // `application/http` messages or nested `multipart/mixed` change sets. A
14
+ // FAILED change set is answered by ONE part — every change in it takes that
15
+ // failure.
16
+ // - JSON (OData JSON Format 4.01 §19): `{requests:[…]}` / `{responses:[…]}`,
17
+ // paired by `id`; a failed atomicity group may be answered once, by the
18
+ // group id.
19
+ //
20
+ // No I/O, no browser. A request that got no response part is a FAILURE
21
+ // (status 0), never a silent pass.
22
+ export class BatchParseError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'BatchParseError';
26
+ }
27
+ }
28
+ const isJson = (ct) => /^\s*application\/json/i.test(ct);
29
+ function boundaryOf(contentType) {
30
+ const m = /boundary\s*=\s*(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
31
+ const b = m?.[1] ?? m?.[2];
32
+ if (!b)
33
+ throw new BatchParseError(`no multipart boundary in content type "${contentType}"`);
34
+ return b;
35
+ }
36
+ /** Split a head/body at the first blank line (CRLF or LF). */
37
+ function splitHead(text) {
38
+ const m = /\r?\n\r?\n/.exec(text);
39
+ if (!m)
40
+ return { head: text, body: '' };
41
+ return { head: text.slice(0, m.index), body: text.slice(m.index + m[0].length) };
42
+ }
43
+ function parseHeaders(head) {
44
+ const out = {};
45
+ for (const line of head.split(/\r?\n/)) {
46
+ const i = line.indexOf(':');
47
+ if (i <= 0)
48
+ continue;
49
+ out[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
50
+ }
51
+ return out;
52
+ }
53
+ /** The parts between `--boundary` delimiters; preamble and epilogue dropped. */
54
+ function splitMultipart(body, boundary) {
55
+ const delim = `--${boundary}`;
56
+ const lines = body.split(/\r?\n/);
57
+ const parts = [];
58
+ let current = null;
59
+ let closed = false;
60
+ let sawDelimiter = false;
61
+ for (const line of lines) {
62
+ const t = line.trimEnd();
63
+ if (t === delim || t === `${delim}--`) {
64
+ sawDelimiter = true;
65
+ if (current)
66
+ parts.push(current);
67
+ if (t === `${delim}--`) {
68
+ closed = true;
69
+ current = null;
70
+ break;
71
+ }
72
+ current = [];
73
+ continue;
74
+ }
75
+ if (current)
76
+ current.push(line);
77
+ }
78
+ if (!sawDelimiter)
79
+ throw new BatchParseError(`multipart boundary "${boundary}" does not occur in the body`);
80
+ if (current && !closed)
81
+ parts.push(current);
82
+ return parts.map((p) => {
83
+ const { head, body: rest } = splitHead(p.join('\r\n'));
84
+ return { headers: parseHeaders(head), body: rest };
85
+ });
86
+ }
87
+ function isChangeSet(part) {
88
+ return /^multipart\/mixed/i.test(part.headers['content-type'] ?? '');
89
+ }
90
+ function requestLineOf(part) {
91
+ const first = part.body.replace(/^\s+/, '').split(/\r?\n/)[0] ?? '';
92
+ const m = /^([A-Z]+)\s+(\S+)(?:\s+HTTP\/\d(?:\.\d)?)?\s*$/.exec(first);
93
+ if (!m)
94
+ throw new BatchParseError(`not an HTTP request line: "${first.slice(0, 120)}"`);
95
+ return { method: m[1], url: m[2] };
96
+ }
97
+ /** A human message from an error body or the sap-messages header, else the status line. */
98
+ function errorMessageOf(status, reason, headers, body) {
99
+ if (status < 400)
100
+ return undefined;
101
+ const fromJson = (v) => {
102
+ if (!v || typeof v !== 'object')
103
+ return undefined;
104
+ const o = v;
105
+ const err = (o.error && typeof o.error === 'object' ? o.error : o);
106
+ const msg = err.message;
107
+ if (typeof msg === 'string' && msg)
108
+ return msg;
109
+ if (msg && typeof msg === 'object' && typeof msg.value === 'string') {
110
+ return msg.value;
111
+ }
112
+ return undefined;
113
+ };
114
+ const trimmed = body.trim();
115
+ if (trimmed.startsWith('{')) {
116
+ try {
117
+ const m = fromJson(JSON.parse(trimmed));
118
+ if (m)
119
+ return m;
120
+ }
121
+ catch {
122
+ /* fall through */
123
+ }
124
+ }
125
+ const sapMessages = headers['sap-messages'];
126
+ if (sapMessages) {
127
+ try {
128
+ const parsed = JSON.parse(sapMessages);
129
+ const list = Array.isArray(parsed) ? parsed : [parsed];
130
+ const msgs = list.map((x) => (x && typeof x.message === 'string' ? x.message : '')).filter((x) => x);
131
+ if (msgs.length > 0)
132
+ return msgs.join(' | ');
133
+ }
134
+ catch {
135
+ /* fall through */
136
+ }
137
+ }
138
+ return `${status}${reason ? ` ${reason}` : ''}`;
139
+ }
140
+ function responseLineOf(part, group) {
141
+ const { head, body } = splitHead(part.body.replace(/^\s+/, ''));
142
+ const [statusLine = '', ...headerLines] = head.split(/\r?\n/);
143
+ const m = /^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/.exec(statusLine.trim());
144
+ if (!m)
145
+ throw new BatchParseError(`not an HTTP status line: "${statusLine.slice(0, 120)}"`);
146
+ const status = Number(m[1]);
147
+ const headers = parseHeaders(headerLines.join('\n'));
148
+ const errorMessage = errorMessageOf(status, m[2].trim(), headers, body);
149
+ const id = part.headers['content-id'];
150
+ return { status, group, ...(id ? { id } : {}), ...(errorMessage ? { errorMessage } : {}) };
151
+ }
152
+ function parseJson(body, key) {
153
+ let parsed;
154
+ try {
155
+ parsed = JSON.parse(body);
156
+ }
157
+ catch {
158
+ throw new BatchParseError(`JSON $batch body is not JSON`);
159
+ }
160
+ const list = parsed?.[key];
161
+ if (!Array.isArray(list))
162
+ throw new BatchParseError(`JSON $batch body has no "${key}" array`);
163
+ return list;
164
+ }
165
+ /** One line per inner request, in order. */
166
+ export function parseBatchRequest(body, contentType) {
167
+ if (isJson(contentType)) {
168
+ return parseJson(body, 'requests').map((r, i) => ({
169
+ method: String(r.method ?? '').toUpperCase(),
170
+ url: String(r.url ?? ''),
171
+ ...(r.id !== undefined ? { id: String(r.id) } : {}),
172
+ group: r.atomicityGroup !== undefined ? String(r.atomicityGroup) : `#${i}`,
173
+ }));
174
+ }
175
+ const out = [];
176
+ splitMultipart(body, boundaryOf(contentType)).forEach((part, group) => {
177
+ const members = isChangeSet(part) ? splitMultipart(part.body, boundaryOf(part.headers['content-type'])) : [part];
178
+ for (const m of members) {
179
+ const id = m.headers['content-id'];
180
+ out.push({ ...requestLineOf(m), ...(id ? { id } : {}), group });
181
+ }
182
+ });
183
+ return out;
184
+ }
185
+ /** One line per inner response part, in order (a failed change set is ONE line). */
186
+ export function parseBatchResponse(body, contentType) {
187
+ if (isJson(contentType)) {
188
+ return parseJson(body, 'responses').map((r, i) => {
189
+ const status = Number(r.status ?? 0);
190
+ const bodyText = r.body === undefined ? '' : typeof r.body === 'string' ? r.body : JSON.stringify(r.body);
191
+ const headers = {};
192
+ for (const [k, v] of Object.entries(r.headers ?? {}))
193
+ headers[k.toLowerCase()] = String(v);
194
+ const errorMessage = errorMessageOf(status, '', headers, bodyText);
195
+ return {
196
+ status,
197
+ ...(r.id !== undefined ? { id: String(r.id) } : {}),
198
+ group: r.atomicityGroup !== undefined ? String(r.atomicityGroup) : `#${i}`,
199
+ ...(errorMessage ? { errorMessage } : {}),
200
+ };
201
+ });
202
+ }
203
+ const out = [];
204
+ splitMultipart(body, boundaryOf(contentType)).forEach((part, group) => {
205
+ if (isChangeSet(part)) {
206
+ for (const m of splitMultipart(part.body, boundaryOf(part.headers['content-type'])))
207
+ out.push(responseLineOf(m, group));
208
+ }
209
+ else {
210
+ out.push(responseLineOf(part, group));
211
+ }
212
+ });
213
+ return out;
214
+ }
215
+ const NO_RESPONSE = 'no response part for this request';
216
+ function part(req, res) {
217
+ if (!res)
218
+ return { method: req.method, url: req.url, status: 0, errorMessage: NO_RESPONSE };
219
+ return {
220
+ method: req.method,
221
+ url: req.url,
222
+ status: res.status,
223
+ ...(res.errorMessage !== undefined ? { errorMessage: res.errorMessage } : {}),
224
+ };
225
+ }
226
+ /**
227
+ * Join the requests with their responses.
228
+ * Multipart: top-level group by group, in order; inside a group by position,
229
+ * and a group answered by ONE part (a failed change set) gives every member
230
+ * that answer. JSON: by id; a request with no answer of its own takes the
231
+ * answer given to its atomicity group.
232
+ */
233
+ export function pairBatch(ex) {
234
+ const reqs = parseBatchRequest(ex.requestBody, ex.requestContentType);
235
+ const resps = parseBatchResponse(ex.responseBody, ex.responseContentType);
236
+ if (isJson(ex.requestContentType)) {
237
+ const byId = new Map(resps.filter((r) => r.id !== undefined).map((r) => [r.id, r]));
238
+ return reqs.map((q) => {
239
+ const own = q.id !== undefined ? byId.get(q.id) : undefined;
240
+ const groupAnswer = typeof q.group === 'string' && !q.group.startsWith('#')
241
+ ? resps.find((r) => r.group === q.group && (r.id === undefined || r.id === q.group))
242
+ : undefined;
243
+ return part(q, own ?? groupAnswer);
244
+ });
245
+ }
246
+ const groups = new Map();
247
+ for (const q of reqs)
248
+ groups.set(q.group, [...(groups.get(q.group) ?? []), q]);
249
+ const respGroups = new Map();
250
+ for (const r of resps)
251
+ respGroups.set(r.group, [...(respGroups.get(r.group) ?? []), r]);
252
+ // Response groups are matched to request groups by ORDER, not by index: a
253
+ // failed change set shrinks to one part but keeps its place.
254
+ const reqGroupKeys = [...groups.keys()];
255
+ const respGroupKeys = [...respGroups.keys()];
256
+ const out = [];
257
+ reqGroupKeys.forEach((gk, gi) => {
258
+ const members = groups.get(gk);
259
+ const answers = respGroups.get(respGroupKeys[gi]) ?? [];
260
+ if (members.length > 1 && answers.length === 1) {
261
+ for (const m of members)
262
+ out.push(part(m, answers[0]));
263
+ }
264
+ else {
265
+ members.forEach((m, i) => out.push(part(m, answers[i])));
266
+ }
267
+ });
268
+ return out;
269
+ }
270
+ /** The inner parts that failed: status >= 400, or no response at all. */
271
+ export function innerFailures(parts) {
272
+ return parts.filter((p) => p.status === 0 || p.status >= 400);
273
+ }