From f7841857291a8e7a7f247b1221c01841ba56be46 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 15 Oct 2026 16:52:25 -0400 Subject: [PATCH 1/2] fix(admin-health): make URL Health tile preload-buildable, not CDN-dependent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new URL Health admin tile (app/admin/health) failed to render with a runtime ModuleError: "failed to load sap/ui/model/odata/v4/library.js ... script load error". Three defects compounded: 1. manifest + (intended) ui5.yaml listed `sap.ui.model.odata.v4` under dependency libs. That is NOT a SAPUI5 framework library — the OData V4 model ships inside `sap.ui.core`. `ui5 build` can't resolve it ("Could not find library"), so copy-components.js silently fell back from the preload build to a raw copy that ships NO Component-preload.js. UI5 then lazy-loaded each declared lib from the CDN at runtime, and the bogus odata/v4 path 404'd → the ModuleError. 2. app/admin/health had no ui5.yaml AND no package.json. `ui5 build` needs both; without them it walked up to app/admin (tutorials-admin-fiori, ui5:null) and failed project resolution — another path to the same raw-copy fallback. Fix: add app/admin/health/{ui5.yaml,package.json} matching the sibling apps, and drop the non-existent sap.ui.model.odata.v4 lib from both the manifest and ui5.yaml (sap.m + sap.ui.core only). Result: 52/52 admin components now preload-build (was 51/52, health raw-copied); health ships a real Component-preload.js and no longer hits the CDN. Guard so this class can't ship silently again: discoverComponents() now throws at build time when a non-excluded admin component lacks its ui5.yaml, with a message pointing at the raw-copy/CDN failure mode and the PRELOAD_EXCLUDE opt-out. preload-components.cjs threads PRELOAD_EXCLUDE through so excluded tiles stay exempt. Unit tests cover the real tree, a throwing fixture, and the exempt path. --- .../scripts/discover-admin-components.js | 31 ++++++++++++- .../scripts/preload-components.cjs | 4 +- app/admin/health/package.json | 13 ++++++ app/admin/health/ui5.yaml | 10 ++++ app/admin/health/webapp/manifest.json | 3 +- .../admin-shell-manifest-generator.test.js | 46 ++++++++++++++++++- 6 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 app/admin/health/package.json create mode 100644 app/admin/health/ui5.yaml diff --git a/app/admin-shell/scripts/discover-admin-components.js b/app/admin-shell/scripts/discover-admin-components.js index 10dc0d3c5..6031a5b1d 100644 --- a/app/admin-shell/scripts/discover-admin-components.js +++ b/app/admin-shell/scripts/discover-admin-components.js @@ -36,13 +36,32 @@ function isFioriComponent(adminDir, folder) { existsSync(join(webapp, 'manifest.json')) } +// A preload-built component (the default for every discovered tile — see +// preload-components.cjs) is run through `ui5 build`, which needs the app's own +// `ui5.yaml`. copy-components.js gates the preload path on `existsSync(ui5.yaml)` +// and SILENTLY falls back to a raw cpSync when it's missing. Raw copy ships the +// unminified source with NO Component-preload.js, so at runtime UI5 resolves each +// declared lib as an individual module straight from the SAPUI5 CDN. For most +// tiles those libs are already loaded by the shell, so the gap is invisible; a +// freestyle OData-V4 tile (app/admin/health) is the first to depend on +// `sap/ui/model/odata/v4/library.js`, whose raw CDN fetch then fails with a +// ModuleError "script load error". Rather than let that degrade silently, fail +// the build the moment a non-excluded component lacks its ui5.yaml. +function hasUi5Yaml(adminDir, folder) { + return existsSync(join(adminDir, folder, 'ui5.yaml')) +} + /** + * @param {string} [adminDir] — root of the admin apps tree. + * @param {Set} [preloadExclude] — folders intentionally kept on the + * raw-copy path; these are exempt from the ui5.yaml-presence guard. Defaults + * to empty (every tile is preload-built, so every tile needs its ui5.yaml). * @returns {Array<{folder: string, appId: string, camelName: string}>} * `folder` — the on-disk directory name under `app/admin/`. * `appId` — the `sap.app.id` read from the app's own `webapp/manifest.json`. * `camelName` — the last segment of `appId`, matching the folder's camelCase form. */ -function discoverComponents(adminDir = ADMIN_DIR) { +function discoverComponents(adminDir = ADMIN_DIR, preloadExclude = new Set()) { const entries = readdirSync(adminDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) @@ -70,6 +89,16 @@ function discoverComponents(adminDir = ADMIN_DIR) { `expected "${expected}" (camelCase of folder name). ` + `Fix either the folder name or the manifest id so the two agree.`) } + if (!preloadExclude.has(folder) && !hasUi5Yaml(adminDir, folder)) { + throw new Error( + `app/admin/${folder}: missing ui5.yaml. Preload-built components need it ` + + `for \`ui5 build\` to emit a Component-preload.js; without it copy-components.js ` + + `falls back to a raw copy that ships NO preload, so UI5 lazy-loads each lib ` + + `from the CDN at runtime (freestyle OData-V4 tiles then fail with a ` + + `"script load error" ModuleError on sap/ui/model/odata/v4/library.js). ` + + `Add app/admin/${folder}/ui5.yaml (copy a sibling), or add "${folder}" to ` + + `PRELOAD_EXCLUDE in preload-components.cjs to intentionally ship it raw.`) + } components.push({ folder, appId, camelName }) } diff --git a/app/admin-shell/scripts/preload-components.cjs b/app/admin-shell/scripts/preload-components.cjs index ae352734e..a043869bb 100644 --- a/app/admin-shell/scripts/preload-components.cjs +++ b/app/admin-shell/scripts/preload-components.cjs @@ -29,7 +29,9 @@ const { discoverComponents } = require('./discover-admin-components'); const PRELOAD_EXCLUDE = new Set([]); function computePreloadComponents() { - const { components } = discoverComponents(); + // Pass PRELOAD_EXCLUDE so discoverComponents' ui5.yaml-presence guard exempts + // exactly the folders we intentionally keep on the raw-copy path. + const { components } = discoverComponents(undefined, PRELOAD_EXCLUDE); return new Set( components .map(c => c.folder) diff --git a/app/admin/health/package.json b/app/admin/health/package.json new file mode 100644 index 000000000..3dd9010ac --- /dev/null +++ b/app/admin/health/package.json @@ -0,0 +1,13 @@ +{ + "name": "sap.tutorials.admin.health", + "version": "0.0.1", + "private": true, + "description": "Health - Admin UI", + "scripts": { + "start": "fiori run --open index.html", + "build": "ui5 build --clean-dest" + }, + "devDependencies": { + "@ui5/cli": "^4.0.0" + } +} diff --git a/app/admin/health/ui5.yaml b/app/admin/health/ui5.yaml new file mode 100644 index 000000000..de27d7691 --- /dev/null +++ b/app/admin/health/ui5.yaml @@ -0,0 +1,10 @@ +specVersion: "4.0" +metadata: + name: sap.tutorials.admin.health +type: application +framework: + name: SAPUI5 + version: "1.148.12" + libraries: + - name: sap.m + - name: sap.ui.core diff --git a/app/admin/health/webapp/manifest.json b/app/admin/health/webapp/manifest.json index d9f8e1c2c..5c2b73013 100644 --- a/app/admin/health/webapp/manifest.json +++ b/app/admin/health/webapp/manifest.json @@ -33,8 +33,7 @@ "minUI5Version": "1.148.0", "libs": { "sap.m": {}, - "sap.ui.core": {}, - "sap.ui.model.odata.v4": {} + "sap.ui.core": {} } }, "models": { diff --git a/test/unit/admin-shell-manifest-generator.test.js b/test/unit/admin-shell-manifest-generator.test.js index 59de6485f..64d495686 100644 --- a/test/unit/admin-shell-manifest-generator.test.js +++ b/test/unit/admin-shell-manifest-generator.test.js @@ -8,8 +8,9 @@ // mirrors with a scan-driven build step — these tests lock in the invariants. import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { discoverComponents, toCamelCase } from '../../app/admin-shell/scripts/discover-admin-components.js'; import { buildBlocks } from '../../app/admin-shell/scripts/generate-manifest.js'; @@ -116,3 +117,46 @@ describe('toCamelCase', () => { expect(toCamelCase('kgCommunities')).toBe('kgCommunities'); }); }); + +// Guard: every real admin app under app/admin/ carries its own ui5.yaml. +// Without it, copy-components.js silently falls back from the preload build to +// a raw copy that ships NO Component-preload.js; a freestyle OData-V4 tile +// (app/admin/health) then dies at runtime with a "script load error" +// ModuleError on sap/ui/model/odata/v4/library.js. discoverComponents now fails +// the build the moment a non-excluded component lacks its ui5.yaml. +describe('ui5.yaml-presence guard', () => { + it('every real admin component ships a ui5.yaml', () => { + // Real tree must pass — if this fails, a tile is missing its ui5.yaml. + expect(() => discoverComponents(ADMIN)).not.toThrow(); + }); + + it('throws when a discovered component has no ui5.yaml', () => { + const root = mkdtempSync(join(tmpdir(), 'admin-guard-')); + try { + const webapp = join(root, 'widget', 'webapp'); + mkdirSync(webapp, { recursive: true }); + writeFileSync(join(webapp, 'Component.js'), '// stub'); + writeFileSync(join(webapp, 'manifest.json'), + JSON.stringify({ 'sap.app': { id: 'sap.tutorials.admin.widget' } })); + // No ui5.yaml written → must throw. + expect(() => discoverComponents(root)).toThrow(/missing ui5\.yaml/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('exempts a component listed in the preload-exclude set', () => { + const root = mkdtempSync(join(tmpdir(), 'admin-guard-')); + try { + const webapp = join(root, 'widget', 'webapp'); + mkdirSync(webapp, { recursive: true }); + writeFileSync(join(webapp, 'Component.js'), '// stub'); + writeFileSync(join(webapp, 'manifest.json'), + JSON.stringify({ 'sap.app': { id: 'sap.tutorials.admin.widget' } })); + const { components } = discoverComponents(root, new Set(['widget'])); + expect(components.map(c => c.folder)).toContain('widget'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From aa8213b53ca07261921ada58e6ecb9f0d44a858d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 15 Oct 2026 17:08:51 -0400 Subject: [PATCH 2/2] chore(release): 1.39.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump deploy version 1.39.0 -> 1.39.1 (.deploy/mta.yaml) for the DEV->main promotion and refresh the What's New digest (hugo/data/whats_new.json). Patch release: carries #2796 — make the URL Health admin tile preload-buildable (bundled into the admin UI, not CDN-dependent), a follow-up to the URL Health subsystem shipped in 1.39.0 (not yet deployed to PROD; this is the version that will deploy). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .deploy/mta.yaml | 2 +- hugo/data/whats_new.json | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 12248729e..aaec8ea30 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.39.0 +version: 1.39.1 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/hugo/data/whats_new.json b/hugo/data/whats_new.json index 5ffab9453..b6e062eed 100644 --- a/hugo/data/whats_new.json +++ b/hugo/data/whats_new.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-15T20:33:45.264Z", + "generatedAt": "2026-10-15T21:16:29.187Z", "repos": [ { "key": "tutorials-ims", @@ -8,6 +8,32 @@ } ], "entries": [ + { + "id": "tutorials-ims#2796", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2796, + "title": "URL Health admin tile loads reliably", + "summary": "The new URL Health admin screen is now built into the admin UI bundle instead of depending on a CDN, so it loads reliably in all environments.", + "category": "Fix", + "mergedAt": "2026-10-15T20:56:43Z", + "week": "2026-W42", + "weekStart": "2026-10-12", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2796" + }, + { + "id": "tutorials-ims#2795", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2795, + "title": "Release promotion", + "summary": "Promoted the URL Health subsystem and the 1.39.0 release train to the production release line.", + "category": "Maintenance", + "mergedAt": "2026-10-15T20:39:53Z", + "week": "2026-W42", + "weekStart": "2026-10-12", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2795" + }, { "id": "tutorials-ims#2793", "repo": "tutorials-ims",