@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.
- package/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +58 -21
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
/**
|
|
24
|
-
|
|
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
|
|
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(
|
|
38
|
-
const missing = expectedColumns.filter((c) => !present.has(c
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|