@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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// cspeach-cli/test-harness/fe-draft/routes.ts
|
|
2
|
+
//
|
|
3
|
+
// L1-2b — the PURE half of the offline Fiori Elements harness.
|
|
4
|
+
//
|
|
5
|
+
// The harness serves a scaffolded app the way `fiori run` serves it for a real
|
|
6
|
+
// preview (see the scaffold's ui5.yaml): `/resources` + `/test-resources` come
|
|
7
|
+
// from the UI5 CDN, `/sap/...` goes to "the backend", everything else is a file
|
|
8
|
+
// under `webapp/`. Here "the backend" is @sap-ux/fe-mockserver reading the
|
|
9
|
+
// CAPTURED $metadata in place. No socket, no network, no mockserver import in
|
|
10
|
+
// this file — it is unit-tested in `pnpm test` (test/unit/fe-harness-routes.test.ts).
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { PINNED_UI5_VERSION } from '../../src/tools/fiori/ui5-version.js';
|
|
13
|
+
|
|
14
|
+
/** The origin fiori-tools-proxy's `ui5:` block points at in every scaffolded ui5.yaml. */
|
|
15
|
+
export const UI5_CDN_ORIGIN = 'https://ui5.sap.com';
|
|
16
|
+
|
|
17
|
+
export type HarnessRoute =
|
|
18
|
+
| { kind: 'ui5'; upstream: string }
|
|
19
|
+
| { kind: 'mock' }
|
|
20
|
+
/** The UI5 flexibility (LREP) service a real ABAP backend answers; the mockserver does not. */
|
|
21
|
+
| { kind: 'flex'; what: 'settings' | 'data' }
|
|
22
|
+
| { kind: 'static'; file: string }
|
|
23
|
+
| { kind: 'reject'; status: 400 | 404; reason: string };
|
|
24
|
+
|
|
25
|
+
export interface RouteOptions {
|
|
26
|
+
/** Absolute path of the app's `webapp/` folder. */
|
|
27
|
+
webappDir: string;
|
|
28
|
+
/** UI5 version the app was scaffolded for (manifest minUI5Version). */
|
|
29
|
+
ui5Version: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
33
|
+
|
|
34
|
+
/** Decide where one browser request goes. Pure. */
|
|
35
|
+
export function routeRequest(rawUrl: string, opts: RouteOptions): HarnessRoute {
|
|
36
|
+
if (!VERSION.test(opts.ui5Version)) {
|
|
37
|
+
throw new Error(`UI5 version must be a plain x.y.z, got "${opts.ui5Version}"`);
|
|
38
|
+
}
|
|
39
|
+
const q = rawUrl.indexOf('?');
|
|
40
|
+
const rawPath = q === -1 ? rawUrl : rawUrl.slice(0, q);
|
|
41
|
+
const query = q === -1 ? '' : rawUrl.slice(q);
|
|
42
|
+
|
|
43
|
+
if (rawPath.startsWith('/resources/') || rawPath.startsWith('/test-resources/')) {
|
|
44
|
+
return { kind: 'ui5', upstream: `${UI5_CDN_ORIGIN}/${opts.ui5Version}${rawPath}${query}` };
|
|
45
|
+
}
|
|
46
|
+
if (rawPath === '/sap/bc/lrep/flex/settings') return { kind: 'flex', what: 'settings' };
|
|
47
|
+
if (rawPath.startsWith('/sap/bc/lrep/flex/data/')) return { kind: 'flex', what: 'data' };
|
|
48
|
+
if (rawPath.startsWith('/sap/')) return { kind: 'mock' };
|
|
49
|
+
|
|
50
|
+
let decoded: string;
|
|
51
|
+
try {
|
|
52
|
+
decoded = decodeURIComponent(rawPath);
|
|
53
|
+
} catch {
|
|
54
|
+
return { kind: 'reject', status: 400, reason: 'malformed percent-encoding' };
|
|
55
|
+
}
|
|
56
|
+
const rel = decoded === '/' ? 'index.html' : decoded.replace(/^\/+/, '');
|
|
57
|
+
const root = path.resolve(opts.webappDir);
|
|
58
|
+
const file = path.resolve(root, rel);
|
|
59
|
+
if (file !== root && !file.startsWith(root + path.sep)) {
|
|
60
|
+
return { kind: 'reject', status: 400, reason: 'path leaves webapp/' };
|
|
61
|
+
}
|
|
62
|
+
return { kind: 'static', file };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MockServiceInput {
|
|
66
|
+
/** Service URL path as the manifest names it, WITHOUT the trailing slash. */
|
|
67
|
+
urlPath: string;
|
|
68
|
+
/** Absolute path of a captured `$metadata` document — read in place, never copied. */
|
|
69
|
+
metadataPath: string;
|
|
70
|
+
/** Where the mockserver may look for / keep mock data. Never the fixture folder. */
|
|
71
|
+
mockdataPath: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The fe-mockserver configuration for one captured service. `mockdataPath`
|
|
76
|
+
* must not be the folder that holds the captured metadata: the mockserver
|
|
77
|
+
* defaults to that folder, and the fixture scrub guard fails on any file there
|
|
78
|
+
* it does not know.
|
|
79
|
+
*/
|
|
80
|
+
export function mockServerConfig(input: MockServiceInput): {
|
|
81
|
+
services: Array<MockServiceInput & { generateMockData: true }>;
|
|
82
|
+
annotations: never[];
|
|
83
|
+
} {
|
|
84
|
+
if (input.urlPath.endsWith('/')) {
|
|
85
|
+
throw new Error(`urlPath must not end with a trailing slash: ${input.urlPath}`);
|
|
86
|
+
}
|
|
87
|
+
if (path.resolve(input.mockdataPath) === path.resolve(path.dirname(input.metadataPath))) {
|
|
88
|
+
throw new Error('mockdataPath must not be the fixture folder that holds the captured metadata');
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
services: [
|
|
92
|
+
{
|
|
93
|
+
urlPath: input.urlPath,
|
|
94
|
+
metadataPath: input.metadataPath,
|
|
95
|
+
mockdataPath: input.mockdataPath,
|
|
96
|
+
generateMockData: true,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
annotations: [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface AppFacts {
|
|
104
|
+
appId: string;
|
|
105
|
+
/** mainService uri without its trailing slash. */
|
|
106
|
+
servicePath: string;
|
|
107
|
+
ui5Version: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read what the harness needs from a scaffolded manifest.json. */
|
|
111
|
+
export function appFactsFromManifest(manifest: unknown): AppFacts {
|
|
112
|
+
const m = manifest as {
|
|
113
|
+
'sap.app'?: { id?: string; dataSources?: Record<string, { uri?: string }> };
|
|
114
|
+
'sap.ui5'?: { dependencies?: { minUI5Version?: string } };
|
|
115
|
+
};
|
|
116
|
+
const uri = m['sap.app']?.dataSources?.mainService?.uri;
|
|
117
|
+
if (!uri) throw new Error('manifest.json has no sap.app.dataSources.mainService.uri');
|
|
118
|
+
const ui5Version = m['sap.ui5']?.dependencies?.minUI5Version;
|
|
119
|
+
if (!ui5Version) throw new Error('manifest.json has no sap.ui5.dependencies.minUI5Version');
|
|
120
|
+
return {
|
|
121
|
+
appId: m['sap.app']?.id ?? '',
|
|
122
|
+
servicePath: uri.replace(/\/+$/, ''),
|
|
123
|
+
ui5Version,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The part of https://ui5.sap.com/neo-app.json the harness reads: one route per hosted version. */
|
|
128
|
+
export interface NeoApp {
|
|
129
|
+
routes?: Array<{ path?: string }>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hostedVersions(neoApp: NeoApp): string[] {
|
|
133
|
+
return (neoApp.routes ?? [])
|
|
134
|
+
.map((r) => (r.path ?? '').replace(/^\//, ''))
|
|
135
|
+
.filter((v) => VERSION.test(v));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Is this exact x.y.z on the CDN? (SAP retires old patches; only some stay hosted.) */
|
|
139
|
+
export function isHosted(version: string, neoApp: NeoApp): boolean {
|
|
140
|
+
return hostedVersions(neoApp).includes(version);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The highest hosted patch of `minor` ("1.120" → "1.120.51"), or undefined. */
|
|
144
|
+
export function latestHostedPatch(minor: string, neoApp: NeoApp): string | undefined {
|
|
145
|
+
const patches = hostedVersions(neoApp)
|
|
146
|
+
.filter((v) => v.startsWith(`${minor}.`) && v.split('.').length === 3)
|
|
147
|
+
.map((v) => ({ v, p: Number(v.split('.')[2]) }))
|
|
148
|
+
.sort((a, b) => b.p - a.p);
|
|
149
|
+
return patches[0]?.v;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The page the scaffold's `npm start` opens (`fiori run --open "<url>"`) — the
|
|
154
|
+
* FLP sandbox entry a developer sees. The harness serves the same page.
|
|
155
|
+
*/
|
|
156
|
+
export function openUrlFromStartScript(script: string): string {
|
|
157
|
+
const m = /--open\s+"([^"]+)"/.exec(script) ?? /--open\s+(\S+)/.exec(script);
|
|
158
|
+
if (!m) throw new Error(`start script has no --open "<url>": ${script}`);
|
|
159
|
+
return m[1]!;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* THE one place the harness's UI5 versions are pinned. Never "latest": a
|
|
164
|
+
* deployed app runs on the customer system's own UI5, and Fiori Elements DOM
|
|
165
|
+
* changes between versions, so a run must say which version it proved.
|
|
166
|
+
*
|
|
167
|
+
* - newest: PINNED_UI5_VERSION — the SAME constant the scaffolds write as their
|
|
168
|
+
* default (src/tools/fiori/ui5-version.ts; 1.120 ≈ S/4HANA 2023).
|
|
169
|
+
* - oldest: the newest hosted patch of 1.84 (≈ S/4HANA 2020), the oldest FE V4
|
|
170
|
+
* release the owner named. Which release is "oldest supported" is his call.
|
|
171
|
+
*
|
|
172
|
+
* SAP retires old patches from the CDN; when a pin disappears the e2e run fails
|
|
173
|
+
* with a message pointing here. Bump the patch, never the policy.
|
|
174
|
+
*/
|
|
175
|
+
export const HARNESS_UI5_VERSIONS = { newest: PINNED_UI5_VERSION, oldest: '1.84.61' } as const;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The long-term UI5 line of every S/4HANA release we support (2020 → 1.84,
|
|
179
|
+
* 2021 → 1.96, 2022 → 1.108, 2023 → 1.120, 2025 → 1.136). Customers run their system's own
|
|
180
|
+
* UI5, so the generated apps and the smoke checks must work on each of them.
|
|
181
|
+
*/
|
|
182
|
+
export const SUPPORTED_UI5_LINES = ['1.84', '1.96', '1.108', '1.120', '1.136'] as const;
|
|
183
|
+
|
|
184
|
+
/** One pinned, hosted patch per supported line (CSP_HARNESS_UI5=all). Bump patches, never the policy. */
|
|
185
|
+
const PINNED_LINE_PATCHES: Record<(typeof SUPPORTED_UI5_LINES)[number], string> = {
|
|
186
|
+
'1.84': HARNESS_UI5_VERSIONS.oldest,
|
|
187
|
+
'1.96': '1.96.50',
|
|
188
|
+
'1.108': '1.108.56',
|
|
189
|
+
'1.120': HARNESS_UI5_VERSIONS.newest,
|
|
190
|
+
'1.136': '1.136.22',
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The newest HOSTED patch of each line (CSP_HARNESS_UI5=latest — the weekly
|
|
195
|
+
* run). Throws when a supported line is no longer on the CDN, so a retired line
|
|
196
|
+
* is a loud decision, not a silently shorter matrix.
|
|
197
|
+
*/
|
|
198
|
+
export function latestPatches(lines: readonly string[], neoApp: NeoApp): string[] {
|
|
199
|
+
return lines.map((line) => {
|
|
200
|
+
const v = latestHostedPatch(line, neoApp);
|
|
201
|
+
if (!v) throw new Error(`UI5 line ${line} is no longer hosted on the CDN — update SUPPORTED_UI5_LINES (test-harness/fe-draft/routes.ts)`);
|
|
202
|
+
return v;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Which pinned version(s) a smoke-e2e run uses, from `CSP_HARNESS_UI5`:
|
|
208
|
+
* unset/empty → newest; `newest` | `oldest` | `both`; or a comma list of x.y.z.
|
|
209
|
+
*/
|
|
210
|
+
export function selectUi5Versions(envValue: string | undefined): string[] {
|
|
211
|
+
const raw = (envValue ?? '').trim();
|
|
212
|
+
if (raw === '' || raw === 'newest') return [HARNESS_UI5_VERSIONS.newest];
|
|
213
|
+
if (raw === 'oldest') return [HARNESS_UI5_VERSIONS.oldest];
|
|
214
|
+
if (raw === 'both') return [HARNESS_UI5_VERSIONS.newest, HARNESS_UI5_VERSIONS.oldest];
|
|
215
|
+
if (raw === 'all') return SUPPORTED_UI5_LINES.map((line) => PINNED_LINE_PATCHES[line]);
|
|
216
|
+
const list = raw.split(',').map((v) => v.trim());
|
|
217
|
+
const bad = list.filter((v) => !VERSION.test(v));
|
|
218
|
+
if (bad.length > 0) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`CSP_HARNESS_UI5 takes newest | oldest | both | all | latest | a comma list of x.y.z — not: ${bad.join(', ')}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return list;
|
|
224
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI5 version matrix the offline FE harness proves (2026-09-24). Each S/4
|
|
3
|
+
* release ships one long-term UI5 line; generated apps and our smoke checks
|
|
4
|
+
* must work on every supported line, not just the one we develop on.
|
|
5
|
+
* all → the pinned patch of every supported line (reproducible)
|
|
6
|
+
* latest → the newest HOSTED patch of every line (the weekly run: catches a
|
|
7
|
+
* UI5 patch that breaks us before a customer hits it)
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
selectUi5Versions,
|
|
12
|
+
latestPatches,
|
|
13
|
+
SUPPORTED_UI5_LINES,
|
|
14
|
+
HARNESS_UI5_VERSIONS,
|
|
15
|
+
} from './routes.js';
|
|
16
|
+
|
|
17
|
+
const neo = (paths: string[]) => ({ routes: paths.map((p) => ({ path: `/${p}` })) });
|
|
18
|
+
|
|
19
|
+
describe('UI5 matrix', () => {
|
|
20
|
+
it('supports the long-term line of every S/4 release from 2020 (2025 = 1.136)', () => {
|
|
21
|
+
expect(SUPPORTED_UI5_LINES).toEqual(['1.84', '1.96', '1.108', '1.120', '1.136']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('`all` runs one pinned patch per supported line, oldest and newest included', () => {
|
|
25
|
+
const v = selectUi5Versions('all');
|
|
26
|
+
expect(v.map((x) => x.split('.').slice(0, 2).join('.'))).toEqual([...SUPPORTED_UI5_LINES]);
|
|
27
|
+
expect(v).toContain(HARNESS_UI5_VERSIONS.oldest);
|
|
28
|
+
expect(v).toContain(HARNESS_UI5_VERSIONS.newest);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('existing selections keep working', () => {
|
|
32
|
+
expect(selectUi5Versions(undefined)).toEqual([HARNESS_UI5_VERSIONS.newest]);
|
|
33
|
+
expect(selectUi5Versions('both')).toEqual([HARNESS_UI5_VERSIONS.newest, HARNESS_UI5_VERSIONS.oldest]);
|
|
34
|
+
expect(selectUi5Versions('1.96.50')).toEqual(['1.96.50']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('latestPatches picks the highest hosted patch of each line', () => {
|
|
38
|
+
const n = neo(['1.84.61', '1.84.62', '1.96.49', '1.96.50', '1.108.56', '1.120.51', '1.120.9']);
|
|
39
|
+
expect(latestPatches(['1.84', '1.96', '1.108', '1.120'], n)).toEqual(['1.84.62', '1.96.50', '1.108.56', '1.120.51']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('latestPatches fails loudly when a supported line is no longer hosted', () => {
|
|
43
|
+
expect(() => latestPatches(['1.84', '1.96'], neo(['1.84.62']))).toThrow(/1\.96/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// cspeach-cli/test-harness/fe-draft/server.ts
|
|
2
|
+
//
|
|
3
|
+
// L1-2b — the offline Fiori Elements harness server.
|
|
4
|
+
//
|
|
5
|
+
// One loopback HTTP server per app, shaped like the scaffold's own ui5.yaml
|
|
6
|
+
// (the `fiori run` preview): `/resources` + `/test-resources` → the UI5 CDN,
|
|
7
|
+
// `/sap/...` → @sap-ux/fe-mockserver (in place of the SAP backend), everything
|
|
8
|
+
// else → a file under `webapp/`. The mockserver reads the CAPTURED `$metadata`
|
|
9
|
+
// where it sits in `src/tools/fiori/__tests__/fixtures/metadata/` — nothing is
|
|
10
|
+
// copied, nothing in that folder is written.
|
|
11
|
+
//
|
|
12
|
+
// Test-only code. It imports a devDependency and lives outside `src/`, so it
|
|
13
|
+
// never reaches `dist/`. The only network it opens is to UI5_CDN_ORIGIN.
|
|
14
|
+
import * as http from 'node:http';
|
|
15
|
+
import * as https from 'node:https';
|
|
16
|
+
import { readFile, writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import type { AddressInfo } from 'node:net';
|
|
20
|
+
import FEMiddleware from '@sap-ux/ui5-middleware-fe-mockserver';
|
|
21
|
+
import manifestEnhancer from '@ui5/builder/processors/manifestEnhancer';
|
|
22
|
+
import fsInterface from '@ui5/fs/fsInterface';
|
|
23
|
+
import { createAdapter } from '@ui5/fs/resourceFactory';
|
|
24
|
+
import {
|
|
25
|
+
routeRequest,
|
|
26
|
+
mockServerConfig,
|
|
27
|
+
appFactsFromManifest,
|
|
28
|
+
UI5_CDN_ORIGIN,
|
|
29
|
+
type NeoApp,
|
|
30
|
+
} from './routes.js';
|
|
31
|
+
|
|
32
|
+
export interface HarnessServer {
|
|
33
|
+
/** e.g. http://127.0.0.1:53211 */
|
|
34
|
+
origin: string;
|
|
35
|
+
/** Every `/sap/...` request the mockserver answered: method, url, status. */
|
|
36
|
+
mockLog: Array<{ method: string; url: string; status: number }>;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
41
|
+
'.html': 'text/html; charset=utf-8',
|
|
42
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
43
|
+
'.json': 'application/json; charset=utf-8',
|
|
44
|
+
'.properties': 'text/plain; charset=utf-8',
|
|
45
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
46
|
+
'.css': 'text/css; charset=utf-8',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const EMPTY_FLEX_DATA = {
|
|
50
|
+
changes: [],
|
|
51
|
+
compVariants: [],
|
|
52
|
+
variants: [],
|
|
53
|
+
variantChanges: [],
|
|
54
|
+
variantDependentControlChanges: [],
|
|
55
|
+
variantManagementChanges: [],
|
|
56
|
+
ui2personalization: {},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** UI5 CDN answers, kept for the life of the test process (the same files every boot). */
|
|
60
|
+
const cdnCache = new Map<string, { status: number; headers: http.OutgoingHttpHeaders; body: Buffer }>();
|
|
61
|
+
|
|
62
|
+
function fetchCdn(upstream: string): Promise<{ status: number; headers: http.OutgoingHttpHeaders; body: Buffer }> {
|
|
63
|
+
const hit = cdnCache.get(upstream);
|
|
64
|
+
if (hit) return Promise.resolve(hit);
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const req = https.get(upstream, { timeout: 60_000 }, (res) => {
|
|
67
|
+
const chunks: Buffer[] = [];
|
|
68
|
+
res.on('data', (c: Buffer) => chunks.push(c));
|
|
69
|
+
res.on('end', () => {
|
|
70
|
+
const headers: http.OutgoingHttpHeaders = {};
|
|
71
|
+
if (res.headers['content-type']) headers['content-type'] = res.headers['content-type'];
|
|
72
|
+
const entry = { status: res.statusCode ?? 502, headers, body: Buffer.concat(chunks) };
|
|
73
|
+
if (entry.status === 200) cdnCache.set(upstream, entry);
|
|
74
|
+
resolve(entry);
|
|
75
|
+
});
|
|
76
|
+
res.on('error', reject);
|
|
77
|
+
});
|
|
78
|
+
req.on('timeout', () => req.destroy(new Error(`UI5 CDN timeout: ${upstream}`)));
|
|
79
|
+
req.on('error', reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The CDN's list of hosted versions (https://ui5.sap.com/neo-app.json). */
|
|
84
|
+
export async function fetchNeoApp(): Promise<NeoApp> {
|
|
85
|
+
const r = await fetchCdn(`${UI5_CDN_ORIGIN}/neo-app.json`);
|
|
86
|
+
if (r.status !== 200) throw new Error(`UI5 CDN neo-app.json answered HTTP ${r.status}`);
|
|
87
|
+
return JSON.parse(r.body.toString('utf8')) as NeoApp;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StartHarnessOptions {
|
|
91
|
+
/** Absolute path of the scaffolded app (the folder holding webapp/). */
|
|
92
|
+
appDir: string;
|
|
93
|
+
/** Absolute path of the captured $metadata the mockserver serves. */
|
|
94
|
+
metadataPath: string;
|
|
95
|
+
/**
|
|
96
|
+
* Fixed rows per entity set, written as <EntitySet>.json into the harness's own
|
|
97
|
+
* temp mock-data folder. Sets not named here get the mockserver's generated data
|
|
98
|
+
* (random, including HasDraftEntity — not a clean start for draft tests).
|
|
99
|
+
*/
|
|
100
|
+
mockRows?: Record<string, unknown[]>;
|
|
101
|
+
/**
|
|
102
|
+
* L1-3: serve a VARIANT of the captured document — e.g. variants.ts
|
|
103
|
+
* withCodeTexts / withoutLabels. The transform runs in memory on the capture;
|
|
104
|
+
* the result is written into the harness's own temp folder, never next to the
|
|
105
|
+
* capture.
|
|
106
|
+
*/
|
|
107
|
+
transformMetadata?: (xml: string) => string;
|
|
108
|
+
/** Receives exactly the document the mock backend serves (for auditMetadata). */
|
|
109
|
+
onServedMetadata?: (xml: string) => void;
|
|
110
|
+
/**
|
|
111
|
+
* L1-5b: fe-mockserver behaviour modules, written as <EntitySet>.js (CommonJS
|
|
112
|
+
* source) into the harness's temp mock-data folder next to the JSON rows —
|
|
113
|
+
* e.g. variants.ts inspectionLogHooks.
|
|
114
|
+
*/
|
|
115
|
+
mockScripts?: Record<string, string>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* manifest.json exactly as `fiori run` serves it (2026-09-25). UI5 tooling 4's
|
|
120
|
+
* dev server passes every app manifest through the builder's manifestEnhancer,
|
|
121
|
+
* which adds supportedLocales from the i18n files the app ships. Serving the raw
|
|
122
|
+
* file hid a boot error every real preview shows ("fallback locale 'en' is not
|
|
123
|
+
* contained in the list of supported locales").
|
|
124
|
+
*/
|
|
125
|
+
export async function enhancedManifest(webappDir: string): Promise<string> {
|
|
126
|
+
const reader = createAdapter({ fsBasePath: webappDir, virBasePath: '/' });
|
|
127
|
+
const resource = await reader.byPath('/manifest.json');
|
|
128
|
+
if (!resource) throw new Error(`no manifest.json in ${webappDir}`);
|
|
129
|
+
await manifestEnhancer({ resources: [resource], fs: fsInterface(reader) });
|
|
130
|
+
return resource.getString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Start the harness for one scaffolded app on 127.0.0.1, a free port. */
|
|
134
|
+
export async function startHarness(opts: StartHarnessOptions): Promise<HarnessServer> {
|
|
135
|
+
const webappDir = path.join(opts.appDir, 'webapp');
|
|
136
|
+
const manifest = JSON.parse(await readFile(path.join(webappDir, 'manifest.json'), 'utf8'));
|
|
137
|
+
const facts = appFactsFromManifest(manifest);
|
|
138
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'csp-fe-harness-mock-'));
|
|
139
|
+
const mockdataPath = path.join(tempRoot, 'mockdata');
|
|
140
|
+
await mkdir(mockdataPath, { recursive: true });
|
|
141
|
+
let metadataPath = opts.metadataPath;
|
|
142
|
+
const captured = await readFile(opts.metadataPath, 'utf8');
|
|
143
|
+
const served = opts.transformMetadata ? opts.transformMetadata(captured) : captured;
|
|
144
|
+
if (opts.transformMetadata) {
|
|
145
|
+
await mkdir(path.join(tempRoot, 'metadata'), { recursive: true });
|
|
146
|
+
metadataPath = path.join(tempRoot, 'metadata', 'service.xml');
|
|
147
|
+
await writeFile(metadataPath, served, 'utf8');
|
|
148
|
+
}
|
|
149
|
+
opts.onServedMetadata?.(served);
|
|
150
|
+
for (const [set, rows] of Object.entries(opts.mockRows ?? {})) {
|
|
151
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(set)) throw new Error(`not an entity set name: ${set}`);
|
|
152
|
+
await writeFile(path.join(mockdataPath, `${set}.json`), JSON.stringify(rows));
|
|
153
|
+
}
|
|
154
|
+
for (const [set, source] of Object.entries(opts.mockScripts ?? {})) {
|
|
155
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(set)) throw new Error(`not an entity set name: ${set}`);
|
|
156
|
+
await writeFile(path.join(mockdataPath, `${set}.js`), source);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const configuration = mockServerConfig({
|
|
160
|
+
urlPath: facts.servicePath,
|
|
161
|
+
metadataPath,
|
|
162
|
+
mockdataPath,
|
|
163
|
+
});
|
|
164
|
+
// The UI5 tooling calls the middleware with its own project handles; the
|
|
165
|
+
// mockserver only asks them for a base path, and every path we pass is absolute.
|
|
166
|
+
const mockRouter = (await FEMiddleware({
|
|
167
|
+
options: { configuration },
|
|
168
|
+
resources: {},
|
|
169
|
+
middlewareUtil: { getProject: () => ({ getSourcePath: () => webappDir }) },
|
|
170
|
+
} as never)) as unknown as (req: http.IncomingMessage, res: http.ServerResponse, next: (err?: unknown) => void) => void;
|
|
171
|
+
|
|
172
|
+
const mockLog: HarnessServer['mockLog'] = [];
|
|
173
|
+
|
|
174
|
+
const server = http.createServer((req, res) => {
|
|
175
|
+
const route = routeRequest(req.url ?? '/', { webappDir, ui5Version: facts.ui5Version });
|
|
176
|
+
if (route.kind === 'reject') {
|
|
177
|
+
res.writeHead(route.status, { 'content-type': 'text/plain' }).end(route.reason);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (route.kind === 'flex') {
|
|
181
|
+
// What an ABAP backend with no key-user changes answers. The mockserver
|
|
182
|
+
// has no LREP; without this every boot logs two 404s a real preview does not.
|
|
183
|
+
const body = route.what === 'settings' ? {} : EMPTY_FLEX_DATA;
|
|
184
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (route.kind === 'mock') {
|
|
188
|
+
const url = req.url ?? '';
|
|
189
|
+
res.on('finish', () => mockLog.push({ method: req.method ?? '', url, status: res.statusCode }));
|
|
190
|
+
mockRouter(req, res, (err) => {
|
|
191
|
+
if (res.headersSent) return;
|
|
192
|
+
res.writeHead(err ? 500 : 404, { 'content-type': 'text/plain' }).end(err ? String(err) : 'not a mock route');
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (route.kind === 'ui5') {
|
|
197
|
+
fetchCdn(route.upstream).then(
|
|
198
|
+
(r) => res.writeHead(r.status, r.headers).end(r.body),
|
|
199
|
+
(err: unknown) => res.writeHead(502, { 'content-type': 'text/plain' }).end(String(err)),
|
|
200
|
+
);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const isManifest = route.file === path.resolve(webappDir, 'manifest.json');
|
|
204
|
+
(isManifest ? enhancedManifest(webappDir) : readFile(route.file)).then(
|
|
205
|
+
(body) =>
|
|
206
|
+
res
|
|
207
|
+
.writeHead(200, { 'content-type': CONTENT_TYPES[path.extname(route.file)] ?? 'application/octet-stream' })
|
|
208
|
+
.end(body),
|
|
209
|
+
() => res.writeHead(404, { 'content-type': 'text/plain' }).end('not found'),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
214
|
+
const { port } = server.address() as AddressInfo;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
origin: `http://127.0.0.1:${port}`,
|
|
218
|
+
mockLog,
|
|
219
|
+
close: async () => {
|
|
220
|
+
server.closeAllConnections();
|
|
221
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
222
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|