@cspeach/cli 1.1.17 → 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 (225) 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 +80 -22
  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/skills/signing-public-key.js +1 -1
  132. package/dist/standards/standards-init.js +1 -1
  133. package/dist/test-helpers/answer-any.js +46 -0
  134. package/dist/tools/approval.js +27 -11
  135. package/dist/tools/ask-question.js +47 -5
  136. package/dist/tools/filesystem/file-write.js +19 -2
  137. package/dist/tools/fiori/fe-extend.js +16 -1
  138. package/dist/tools/fiori/fe-scaffold.js +105 -3
  139. package/dist/tools/fiori/html-escapes.js +28 -0
  140. package/dist/tools/fiori/metadata/audit.js +605 -0
  141. package/dist/tools/fiori/metadata/references.js +104 -0
  142. package/dist/tools/fiori/metadata/types.js +8 -0
  143. package/dist/tools/fiori/preview/app-guard.js +215 -0
  144. package/dist/tools/fiori/preview/env.js +193 -0
  145. package/dist/tools/fiori/preview/readiness.js +127 -0
  146. package/dist/tools/fiori/preview/registry.js +412 -0
  147. package/dist/tools/fiori/preview/start.js +469 -0
  148. package/dist/tools/fiori/samples/loader.js +20 -5
  149. package/dist/tools/fiori/scaffold.js +18 -6
  150. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  151. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  152. package/dist/tools/fiori/smoke/assertions.js +161 -24
  153. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  154. package/dist/tools/fiori/smoke/batch.js +273 -0
  155. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  156. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  157. package/dist/tools/fiori/smoke/driver.js +171 -4
  158. package/dist/tools/fiori/smoke/evidence.js +35 -0
  159. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  160. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  161. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  162. package/dist/tools/fiori/tools.js +651 -7
  163. package/dist/tools/fiori/ui5-version.js +16 -0
  164. package/dist/tools/index.js +8 -0
  165. package/dist/tools/local-build.js +6 -0
  166. package/dist/tools/sap-read.js +16 -1
  167. package/dist/tools/sap-write.js +122 -10
  168. package/dist/tools/shell/shell_exec.js +30 -12
  169. package/dist/tools/syntax-state.js +67 -0
  170. package/dist/tools/todo.js +23 -18
  171. package/dist/tools/transport-fit.js +462 -0
  172. package/dist/tools/transport-resolution.js +3 -1
  173. package/dist/tools/transport.js +33 -7
  174. package/dist/tools/verify.js +184 -8
  175. package/dist/ui/app.js +284 -146
  176. package/dist/ui/approval-modal.js +41 -15
  177. package/dist/ui/ask-answer-text.js +17 -0
  178. package/dist/ui/body.js +25 -6
  179. package/dist/ui/card-slot.js +98 -0
  180. package/dist/ui/checklist.js +9 -0
  181. package/dist/ui/coaching-picker-classic.js +8 -5
  182. package/dist/ui/command-palette.js +6 -4
  183. package/dist/ui/confirm-request.js +18 -0
  184. package/dist/ui/context-grid.js +2 -1
  185. package/dist/ui/file-palette.js +6 -4
  186. package/dist/ui/files-card-emitter.js +19 -0
  187. package/dist/ui/footer-line.js +16 -0
  188. package/dist/ui/footer.js +105 -104
  189. package/dist/ui/input-wrap.js +122 -0
  190. package/dist/ui/interrupt.js +103 -0
  191. package/dist/ui/key-burst.js +117 -0
  192. package/dist/ui/line-resolution.js +4 -3
  193. package/dist/ui/live-area.js +71 -0
  194. package/dist/ui/login-banner.js +54 -43
  195. package/dist/ui/rewind-panel.js +76 -24
  196. package/dist/ui/role-props.js +47 -0
  197. package/dist/ui/sap-state-store.js +1 -0
  198. package/dist/ui/session-timeline.js +49 -3
  199. package/dist/ui/skill-picker.js +5 -47
  200. package/dist/ui/text-input.js +128 -24
  201. package/dist/ui/todo-emitter.js +19 -0
  202. package/dist/ui/turn-status-emitter.js +28 -2
  203. package/dist/ui/turn-status.js +16 -19
  204. package/dist/ui/use-card-request.js +44 -0
  205. package/dist/ui/widgets/ask-card.js +521 -0
  206. package/dist/ui/widgets/bar-chart.js +11 -5
  207. package/dist/ui/widgets/coaching-picker.js +50 -55
  208. package/dist/ui/widgets/confirm-card.js +193 -0
  209. package/dist/ui/widgets/dep-graph.js +3 -2
  210. package/dist/ui/widgets/diff-viewer.js +6 -5
  211. package/dist/ui/widgets/files-card.js +97 -0
  212. package/dist/ui/widgets/question-card.js +2 -1
  213. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  214. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  215. package/dist/ui/widgets/stack-frames.js +2 -1
  216. package/dist/upgrade-check.js +39 -8
  217. package/package.json +11 -2
  218. package/test-harness/fe-draft/app.ts +188 -0
  219. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  220. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  221. package/test-harness/fe-draft/routes.ts +224 -0
  222. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  223. package/test-harness/fe-draft/server.ts +225 -0
  224. package/test-harness/fe-draft/variants.ts +196 -0
  225. package/vitest.smoke-e2e.config.ts +20 -0
@@ -0,0 +1,461 @@
1
+ /**
2
+ * L1-2b — the `smoke-e2e` project: the lane-1 smoke checks, run by the REAL
3
+ * `runSmoke` → real `openApp` driver, against an app built by the product's own
4
+ * tools and served offline (fe-mockserver on the captured draft `$metadata`).
5
+ *
6
+ * NOT part of `pnpm test` — it needs a local Edge/Chrome and the UI5 CDN (never
7
+ * the VPN, never SAP). Run it with `pnpm test:smoke-e2e`. Skipped, with the
8
+ * reason printed, when no browser is found.
9
+ *
10
+ * Plan rule: every lane-1 check must be green HERE before a gate sitting is
11
+ * booked. A red test in this file is a gate blocker, not a flaky harness —
12
+ * `harness self-check` proves, with the same driver, that the harness serves a
13
+ * working app whose list really reads its rows from the mock backend. Each red
14
+ * test names the finding (F-n) it stands for; the L1-2b report has the list.
15
+ */
16
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
17
+ import { mkdtemp, mkdir, rm, readFile } from 'node:fs/promises';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ import { resolveBrowserExecutable } from '../../src/tools/fiori/smoke/browser.js';
21
+ import { openApp } from '../../src/tools/fiori/smoke/driver.js';
22
+ import { runSmoke, type SmokeResult, type SmokeExercise } from '../../src/tools/fiori/smoke/run-smoke.js';
23
+ import { defaultEntryFile } from '../../src/tools/fiori/preview/start.js';
24
+ import { fioriRenderSmokeHandler, fioriDraftSmokeHandler } from '../../src/tools/fiori/tools.js';
25
+ import { auditMetadata } from '../../src/tools/fiori/metadata/audit.js';
26
+ import type { MetadataAudit } from '../../src/tools/fiori/metadata/types.js';
27
+ import type { ToolContext } from '../../src/tools/index.js';
28
+ import { withCodeTexts, withoutLabels, rowsWithTexts, withValueHelps, VALUE_HELP_ROWS, inspectionLogHooks } from './variants.js';
29
+ import { mintChangeApproval } from '../../src/approvals/jwt.js';
30
+ import type { DraftSmokeResult } from '../../src/tools/fiori/smoke/draft-smoke.js';
31
+ import { startHarness, fetchNeoApp, type HarnessServer } from './server.js';
32
+ import { isHosted, selectUi5Versions, latestPatches, SUPPORTED_UI5_LINES, HARNESS_UI5_VERSIONS, type NeoApp } from './routes.js';
33
+ import {
34
+ buildGoodApp,
35
+ buildBrokenApp,
36
+ buildBrokenPressApp,
37
+ customActionSelector,
38
+ flpEntry,
39
+ BROKEN_MARKER,
40
+ DRAFT_METADATA,
41
+ DRAFT_SERVICE_PATH,
42
+ DRAFT_MAIN_ENTITY,
43
+ HARNESS_MOCK_ROWS,
44
+ INSPECTION_ROWS,
45
+ type HarnessApp,
46
+ } from './app.js';
47
+
48
+ const browser = resolveBrowserExecutable();
49
+ if (!browser) {
50
+ // eslint-disable-next-line no-console
51
+ console.warn('[smoke-e2e] SKIPPED: no Edge/Chrome found (set CSPEACH_BROWSER to one).');
52
+ }
53
+
54
+ const TIMEOUT_MS = 90_000;
55
+ /** The columns the captured service's UI.LineItem names (by label). */
56
+ const EXPECTED_COLUMNS = ['Equipment Name', 'Inspection Date', 'Inspector'];
57
+ /** The list report's "Go" button (FE V4 filter bar). */
58
+ const GO = '[id$="-btnSearch"]';
59
+ /** A data row of the responsive table: a list item that is neither header nor the no-data line. */
60
+ const DATA_ROW = '.sapMListTbl tr.sapMLIB:not(.sapMListTblNoData)';
61
+ const isBatch = (url: string) => /\/\$batch(\?|$)/.test(url);
62
+
63
+ /**
64
+ * Pinned in routes.ts; pick with CSP_HARNESS_UI5=newest|oldest|both|all|latest|x.y.z,…
65
+ * `latest` (the weekly run) resolves the newest hosted patch of every supported line.
66
+ */
67
+ const UI5_VERSIONS =
68
+ process.env.CSP_HARNESS_UI5?.trim() === 'latest'
69
+ ? latestPatches(SUPPORTED_UI5_LINES, await fetchNeoApp())
70
+ : selectUi5Versions(process.env.CSP_HARNESS_UI5);
71
+
72
+ let root: string;
73
+ let neoApp: NeoApp;
74
+ const servers: HarnessServer[] = [];
75
+
76
+ interface Served {
77
+ app: HarnessApp;
78
+ server: HarnessServer;
79
+ /** fiori_metadata_audit's answer for EXACTLY the document the backend serves. */
80
+ audit: MetadataAudit;
81
+ }
82
+
83
+ async function serve(
84
+ app: HarnessApp,
85
+ variant?: { transform: (xml: string) => string; rows: Record<string, unknown[]>; scripts?: Record<string, string> },
86
+ ): Promise<Served> {
87
+ let servedXml = '';
88
+ const server = await startHarness({
89
+ appDir: app.dir,
90
+ metadataPath: DRAFT_METADATA,
91
+ mockRows: variant?.rows ?? HARNESS_MOCK_ROWS,
92
+ ...(variant ? { transformMetadata: variant.transform } : {}),
93
+ ...(variant?.scripts ? { mockScripts: variant.scripts } : {}),
94
+ onServedMetadata: (xml) => {
95
+ servedXml = xml;
96
+ },
97
+ });
98
+ servers.push(server);
99
+ const audit = auditMetadata({
100
+ servicePath: `${DRAFT_SERVICE_PATH}$metadata`,
101
+ mainXml: servedXml,
102
+ referenceDocs: new Map(),
103
+ });
104
+ if (audit.entitySet !== DRAFT_MAIN_ENTITY) throw new Error(`audit picked ${audit.entitySet}, expected ${DRAFT_MAIN_ENTITY}`);
105
+ return { app, server, audit };
106
+ }
107
+
108
+ const TEXT_ROWS = { [DRAFT_MAIN_ENTITY]: rowsWithTexts(INSPECTION_ROWS) };
109
+ const harnessCtx = (dir: string): ToolContext =>
110
+ ({ adt: {}, sapAlias: 'HARNESS', session: {}, cwd: dir }) as unknown as ToolContext;
111
+
112
+ /**
113
+ * The product path for L1-2/L1-3: fiori_render_smoke in Fiori Elements mode with
114
+ * the audit — expected columns derived from it, labels + no-raw-codes on.
115
+ */
116
+ async function smokeWithAudit(target: Served, exercises: SmokeExercise[], shots: string): Promise<SmokeResult> {
117
+ const r = await fioriRenderSmokeHandler(
118
+ {
119
+ appUrl: `${target.server.origin}${flpEntry(target.app)}`,
120
+ controlKind: 'table',
121
+ exercises,
122
+ metadataAudit: target.audit,
123
+ },
124
+ harnessCtx(root),
125
+ { renderSmokeEnabled: true, timeoutMs: TIMEOUT_MS, artifactDir: path.join(root, shots) },
126
+ );
127
+ if (r.is_error) throw new Error(r.content);
128
+ return JSON.parse(r.content) as SmokeResult;
129
+ }
130
+
131
+ function check(result: { checks: SmokeResult['checks'] }, name: string) {
132
+ return result.checks.find((c) => c.name === name);
133
+ }
134
+
135
+ async function smoke(
136
+ target: { app: HarnessApp; server: HarnessServer },
137
+ exercises: SmokeExercise[],
138
+ shots: string,
139
+ entry: 'flp' | 'index' = 'flp',
140
+ ): Promise<SmokeResult> {
141
+ const url = entry === 'flp' ? `${target.server.origin}${flpEntry(target.app)}` : `${target.server.origin}/index.html`;
142
+ return runSmoke(
143
+ { appUrl: url, controlKind: 'table', expectedColumns: EXPECTED_COLUMNS, exercises },
144
+ { renderSmokeEnabled: true, timeoutMs: TIMEOUT_MS, artifactDir: path.join(root, shots) },
145
+ );
146
+ }
147
+
148
+ describe.skipIf(!browser).each(UI5_VERSIONS)('smoke-e2e — Fiori Elements draft app, offline, UI5 %s', (ui5Version) => {
149
+ let good: Served;
150
+ let broken: Served;
151
+ let brokenPress: Served;
152
+ let goodResult: SmokeResult;
153
+ /** $batch calls the mock backend saw DURING the lane-1 smoke run (none pressed Go). */
154
+ let batchesDuringSmoke: number;
155
+
156
+ beforeAll(async () => {
157
+ root = await mkdtemp(path.join(os.tmpdir(), 'csp-smoke-e2e-'));
158
+ neoApp = await fetchNeoApp();
159
+ // The apps under test are scaffolded for the PINNED version through
160
+ // fiori_scaffold_fe's own ui5Version input (the scaffold's default patch is
161
+ // no longer hosted — F-1, asserted below).
162
+ if (!isHosted(ui5Version, neoApp)) {
163
+ throw new Error(
164
+ `pinned UI5 ${ui5Version} is no longer on the UI5 CDN — bump the patch in HARNESS_UI5_VERSIONS (test-harness/fe-draft/routes.ts)`,
165
+ );
166
+ }
167
+ // eslint-disable-next-line no-console
168
+ console.info(`[smoke-e2e] UI5 ${ui5Version} (pinned) from ui5.sap.com`);
169
+
170
+ // The good app's backend is the capture WITH texts for its coded fields
171
+ // (TextOnly) — the shape gate A demands; built in memory, see variants.ts.
172
+ good = await serve(await buildGoodApp(root, ui5Version), {
173
+ transform: (xml) => withCodeTexts(xml, 'TextOnly'),
174
+ rows: TEXT_ROWS,
175
+ });
176
+ broken = await serve(await buildBrokenApp(root, ui5Version));
177
+ brokenPress = await serve(await buildBrokenPressApp(root, ui5Version));
178
+
179
+ goodResult = await smokeWithAudit(good, [{ kind: 'press', selectorHint: customActionSelector('Ping') }], 'shots-good');
180
+ batchesDuringSmoke = good.server.mockLog.filter((r) => isBatch(r.url)).length;
181
+ }, 600_000);
182
+
183
+ afterAll(async () => {
184
+ for (const s of servers.splice(0)) await s.close();
185
+ if (root) await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
186
+ });
187
+
188
+ // ── The harness itself: must be green, or nothing below means anything ──
189
+ describe('harness self-check', () => {
190
+ it('the good app lists the seeded rows from the mock backend, with labelled headers (real driver)', async () => {
191
+ const page = await openApp(browser!.path, `${good.server.origin}${flpEntry(good.app)}`, TIMEOUT_MS);
192
+ try {
193
+ await page.waitForSelector(GO);
194
+ const before = good.server.mockLog.filter((r) => isBatch(r.url)).length;
195
+ await page.press!(GO);
196
+ await page.waitForSelector(DATA_ROW);
197
+ const rows = await page.queryTexts(DATA_ROW);
198
+ for (const r of INSPECTION_ROWS) expect(rows.join(' | ')).toContain(r.EquipmentName);
199
+ // UI5 1.84 hyphenates header text with soft hyphens (U+00AD); the harness
200
+ // proves the LABELS arrive, so it compares without them (finding F-8).
201
+ const headers = (await page.queryTexts('.sapMColumnHeader, [role="columnheader"]')).map((h) =>
202
+ h.replace(/\u00ad/g, ''),
203
+ );
204
+ for (const c of ['Equipment Name', 'Inspection Date']) expect(headers).toContain(c);
205
+ // The rows came from the mock backend: at least one $batch, all 200. (The
206
+ // list loads on open; FE on UI5 1.108 does not search again on Go when no
207
+ // filter changed, so "Go sent a NEW $batch" is not a version-neutral fact.)
208
+ void before;
209
+ const batches = good.server.mockLog.filter((r) => isBatch(r.url));
210
+ expect(batches.length).toBeGreaterThan(0);
211
+ expect(batches.every((b) => b.status === 200)).toBe(true);
212
+ } finally {
213
+ await page.close();
214
+ }
215
+ }, 180_000);
216
+ });
217
+
218
+ // ── Lane-1 checks on the good app: the gate needs every one green ──
219
+ describe('lane-1 checks on the good app (the npm-start FLP sandbox entry)', () => {
220
+ it('ran a real render (verification is smoke, not manual)', () => {
221
+ expect(goodResult.verification, JSON.stringify(goodResult.checks)).toBe('smoke');
222
+ });
223
+
224
+ for (const name of ['boot', 'odata', 'columns', 'rows', 'labels', 'no-raw-codes', 'exercises']) {
225
+ it(`${name} is green`, () => {
226
+ const c = check(goodResult, name);
227
+ expect(c, `no "${name}" check in ${JSON.stringify(goodResult.checks)}`).toBeDefined();
228
+ expect(c!.ok, c!.detail).toBe(true);
229
+ });
230
+ }
231
+
232
+ it('F-3: rows counts DATA rows — not the header row or the "choose Go" placeholder', () => {
233
+ expect(check(goodResult, 'rows')?.detail).toBe(`${INSPECTION_ROWS.length} row(s) rendered`);
234
+ });
235
+
236
+ it('F-2/F-4: odata green is not hollow — the list read reached the backend during the smoke', () => {
237
+ expect(batchesDuringSmoke, 'no $batch was sent before the checks ran: odata judged zero requests').toBeGreaterThan(
238
+ 0,
239
+ );
240
+ });
241
+
242
+ it('columns were derived from the audit (all five LineItem labels), not a hand-written list', () => {
243
+ expect(check(goodResult, 'columns')?.detail).toBe('all 5 expected column(s) present');
244
+ });
245
+
246
+ it('no-raw-codes compared real cells (Severity + Status, 3 rows)', () => {
247
+ expect(check(goodResult, 'no-raw-codes')?.detail).toMatch(/2 coded column\(s\), 3 row\(s\).*6 cell/);
248
+ });
249
+ // L1-5a inner $batch statuses and the L1-5b draft round trip: see the
250
+ // "fiori_draft_smoke" describe below (a rejected Activate inside a 200 $batch).
251
+ });
252
+
253
+ // ── Negative tests: the smoke must go red on a broken app, for the RIGHT reason ──
254
+ describe('negative: a broken app must fail exercises', () => {
255
+ it('F-6: a custom action whose handler throws fails `exercises`, naming the handler error', async () => {
256
+ const r = await smoke(broken, [{ kind: 'press', selectorHint: customActionSelector('Boom') }], 'shots-broken');
257
+ const ex = check(r, 'exercises');
258
+ expect(ex, JSON.stringify(r.checks)).toBeDefined();
259
+ expect(ex!.ok, 'the handler threw and the smoke still said green').toBe(false);
260
+ // Red for the RIGHT reason: the uncaught page error carrying the handler's message.
261
+ expect(ex!.detail).toMatch(/uncaught page error/);
262
+ expect(ex!.detail).toContain(BROKEN_MARKER);
263
+ expect(ex!.detail).not.toMatch(/No element found/);
264
+ }, 240_000);
265
+
266
+ it('a custom action whose handler module is missing fails `exercises` on the load error', async () => {
267
+ const r = await smoke(
268
+ brokenPress,
269
+ [{ kind: 'press', selectorHint: customActionSelector('Boom') }],
270
+ 'shots-broken-press',
271
+ );
272
+ const ex = check(r, 'exercises');
273
+ expect(ex, JSON.stringify(r.checks)).toBeDefined();
274
+ expect(ex!.ok).toBe(false);
275
+ // Red for the right reason — the press ran and UI5 logged the failed load.
276
+ expect(ex!.detail).not.toMatch(/No element found|threw:/i);
277
+ expect(ex!.detail).toMatch(/new console error/);
278
+ }, 240_000);
279
+
280
+ });
281
+
282
+ // ── Negative tests for L1-3: each red for the RIGHT reason ──
283
+ describe('negative: labels and raw codes', () => {
284
+ it('an un-annotated service (no labels) fails `labels`: technical names are shown as headers', async () => {
285
+ const bare = await serve(await buildGoodApp(path.join(root, 'nolabels'), ui5Version), {
286
+ transform: withoutLabels,
287
+ rows: HARNESS_MOCK_ROWS,
288
+ });
289
+ const r = await smokeWithAudit(bare, [], 'shots-nolabels');
290
+ const labels = check(r, 'labels');
291
+ expect(labels?.ok).toBe(false);
292
+ expect(labels?.detail).toMatch(/no label for: EquipmentName/);
293
+ // What the user SEES differs by release: 1.120 falls back to the technical
294
+ // name, 1.84 renders the header empty. Both are the missing label.
295
+ expect(labels?.detail).toMatch(/technical name shown as a header: .*EquipmentName|\d+ column header\(s\) are empty/);
296
+ }, 300_000);
297
+
298
+ it('a coded field with a text but NO TextArrangement ("Open (O)") fails `no-raw-codes`', async () => {
299
+ const textFirst = await serve(await buildGoodApp(path.join(root, 'textfirst'), ui5Version), {
300
+ transform: (xml) => withCodeTexts(xml),
301
+ rows: TEXT_ROWS,
302
+ });
303
+ const r = await smokeWithAudit(textFirst, [], 'shots-textfirst');
304
+ const codes = check(r, 'no-raw-codes');
305
+ expect(codes?.ok).toBe(false);
306
+ expect(codes?.detail).toMatch(/Status .*shows the raw code "O"/);
307
+ expect(check(r, 'labels')?.ok, check(r, 'labels')?.detail).toBe(true); // red for the codes, nothing else
308
+ }, 300_000);
309
+
310
+ it('the service as captured (coded fields with no text at all) fails `no-raw-codes`', async () => {
311
+ const raw = await serve(await buildGoodApp(path.join(root, 'raw'), ui5Version));
312
+ const r = await smokeWithAudit(raw, [], 'shots-raw');
313
+ const codes = check(r, 'no-raw-codes');
314
+ expect(codes?.ok).toBe(false);
315
+ expect(codes?.detail).toMatch(/Severity is coded but has no text/);
316
+ expect(codes?.detail).toMatch(/Status is coded but has no text/);
317
+ }, 300_000);
318
+ });
319
+
320
+ // ── L1-5b: fiori_draft_smoke through the tool, real browser, mock draft backend ──
321
+ describe('fiori_draft_smoke — the draft round trip through the UI (value help, Save, Discard)', () => {
322
+ /** The capture declares Severity IsDigitSequence while the harness seeds letters (H/M/L): FE would refuse "L" on Save. */
323
+ const lettersForSeverity = (xml: string) =>
324
+ xml.replace(/(<Annotations Target="SAP__self\.InspectionLogType\/Severity">(?:(?!<\/Annotations>)[\s\S])*?)<Annotation Term="SAP__common\.IsDigitSequence"\/>/, '$1');
325
+ const STALE = { ...rowsWithTexts(INSPECTION_ROWS)[0]!, InspectionUUID: '00000000-0000-4000-8000-0000000000aa', EquipmentName: 'Left over', Note: 'CSP-SMOKE-20260101000000-0000' };
326
+
327
+ async function serveDraft(name: string, opts: { rejectStatus?: string; stale?: boolean; editSeverity?: boolean } = {}) {
328
+ return serve(await buildGoodApp(path.join(root, name), ui5Version), {
329
+ transform: (xml) => {
330
+ const x = withValueHelps(withCodeTexts(xml, 'TextOnly'));
331
+ return opts.editSeverity ? lettersForSeverity(x) : x;
332
+ },
333
+ rows: {
334
+ [DRAFT_MAIN_ENTITY]: [...rowsWithTexts(INSPECTION_ROWS), ...(opts.stale ? [STALE] : [])],
335
+ ...VALUE_HELP_ROWS,
336
+ },
337
+ scripts: { [DRAFT_MAIN_ENTITY]: inspectionLogHooks(opts.rejectStatus ? { rejectStatus: opts.rejectStatus } : {}) },
338
+ });
339
+ }
340
+
341
+ async function draftSmoke(target: Served, editField: string, shots: string): Promise<DraftSmokeResult> {
342
+ const r = await fioriDraftSmokeHandler(
343
+ {
344
+ appUrl: `${target.server.origin}${flpEntry(target.app)}`,
345
+ // The tool fetches and audits the service itself (the mock backend over HTTP).
346
+ servicePath: DRAFT_SERVICE_PATH,
347
+ markerField: 'Note',
348
+ editField,
349
+ approval_id: await mintChangeApproval(
350
+ { op: 'modify', object: 'ZUI_LAS_INSPECTIONLOG_O4', type: 'DATA' },
351
+ 'harness-session',
352
+ ),
353
+ },
354
+ harnessCtx(root),
355
+ {
356
+ renderSmokeEnabled: true,
357
+ timeoutMs: TIMEOUT_MS,
358
+ artifactDir: path.join(root, shots),
359
+ fetchDoc: async (p: string) => {
360
+ const res = await fetch(`${target.server.origin}${p}`);
361
+ return { status: res.status, body: await res.text() };
362
+ },
363
+ },
364
+ );
365
+ if (r.is_error) throw new Error(r.content);
366
+ return JSON.parse(r.content) as DraftSmokeResult;
367
+ }
368
+
369
+ /** Every row and draft the mock backend holds for the draft root (read over plain HTTP). */
370
+ async function backendRows(target: Served): Promise<Array<Record<string, unknown>>> {
371
+ const out: Array<Record<string, unknown>> = [];
372
+ for (const active of [true, false]) {
373
+ const res = await fetch(
374
+ `${target.server.origin}${DRAFT_SERVICE_PATH}${DRAFT_MAIN_ENTITY}?$filter=IsActiveEntity eq ${active}&$select=InspectionUUID,IsActiveEntity,Note,Status,Severity`,
375
+ );
376
+ out.push(...((await res.json()) as { value: Array<Record<string, unknown>> }).value);
377
+ }
378
+ return out;
379
+ }
380
+ const smokeRows = (rows: Array<Record<string, unknown>>) => rows.filter((r) => String(r.Note ?? '').startsWith('CSP-SMOKE-'));
381
+
382
+ it('green through the fixed-values DROPDOWN (Status): every check, and no CSP-SMOKE row or draft left', async () => {
383
+ const t = await serveDraft('draft-green', { stale: true });
384
+ const r = await draftSmoke(t, 'Status', 'shots-draft-green');
385
+ // eslint-disable-next-line no-console
386
+ console.info(`[smoke-e2e] draft-green: ${r.checks.map((c) => `${c.ok ? 'OK ' : 'RED'} ${c.name} — ${c.detail}`).join(' | ')}`);
387
+ for (const c of r.checks) expect(c.ok, `${c.name}: ${c.detail}`).toBe(true);
388
+ expect(r.verification).toBe('smoke');
389
+ expect(r.passed).toBe(true);
390
+ expect(check(r, 'value-help')?.detail).toMatch(/the popover opened with 2 entries/);
391
+ expect(check(r, 'draft-save')?.detail).toMatch(/Status "" → "C" via value help → Save: activated; the list shows "Closed"/);
392
+ expect(check(r, 'draft-discard')?.detail).toMatch(/the saved "C" is back, no draft left/);
393
+ // the start-of-run sweep removed the stale marker row — and only it
394
+ expect(check(r, 'sweep')?.detail).toMatch(/removed 1 leftover CSP-SMOKE-\* row\(s\)\/draft\(s\): CSP-SMOKE-20260101000000-0000/);
395
+ const after = await backendRows(t);
396
+ expect(smokeRows(after)).toEqual([]);
397
+ // the customer's rows are untouched
398
+ expect(after.filter((x) => x.IsActiveEntity === true).map((x) => x.InspectionUUID).sort()).toEqual(
399
+ INSPECTION_ROWS.map((x) => x.InspectionUUID).sort(),
400
+ );
401
+ expect(after.filter((x) => x.IsActiveEntity === false)).toEqual([]);
402
+ }, 300_000);
403
+
404
+ it('green through the value-help DIALOG (Severity)', async () => {
405
+ const t = await serveDraft('draft-dialog', { editSeverity: true });
406
+ const r = await draftSmoke(t, 'Severity', 'shots-draft-dialog');
407
+ // eslint-disable-next-line no-console
408
+ console.info(`[smoke-e2e] draft-dialog: ${r.checks.map((c) => `${c.ok ? 'OK ' : 'RED'} ${c.name} — ${c.detail}`).join(' | ')}`);
409
+ for (const c of r.checks) expect(c.ok, `${c.name}: ${c.detail}`).toBe(true);
410
+ expect(check(r, 'value-help')?.detail).toMatch(/the dialog opened with 3 entries/);
411
+ expect(smokeRows(await backendRows(t))).toEqual([]);
412
+ }, 300_000);
413
+
414
+ it('negative: the backend REJECTS the activation on Save → the smoke fails (draft-save + inner-400 odata) and still cleans up', async () => {
415
+ const t = await serveDraft('draft-reject', { rejectStatus: 'C' });
416
+ const r = await draftSmoke(t, 'Status', 'shots-draft-reject');
417
+ // eslint-disable-next-line no-console
418
+ console.info(`[smoke-e2e] draft-reject: ${r.checks.map((c) => `${c.ok ? 'OK ' : 'RED'} ${c.name} — ${c.detail}`).join(' | ')}`);
419
+ expect(r.passed).toBe(false);
420
+ expect(check(r, 'setup')?.ok, check(r, 'setup')?.detail).toBe(true); // setup activated with Status O
421
+ expect(check(r, 'draft-save')?.ok).toBe(false);
422
+ expect(check(r, 'no-error-dialogs')?.ok).toBe(false);
423
+ // L1-5a in a real browser: the outer $batch was 200, the inner Activate 400
424
+ const odata = check(r, 'odata');
425
+ expect(odata?.ok).toBe(false);
426
+ expect(odata?.detail).toMatch(/failed inner \$batch request\(s\): 400 POST .*\.Activate — CSP-HARNESS-REJECT: status C cannot be saved/);
427
+ expect(check(r, 'cleanup')?.ok, check(r, 'cleanup')?.detail).toBe(true);
428
+ expect(smokeRows(await backendRows(t))).toEqual([]);
429
+ expect((await backendRows(t)).filter((x) => x.IsActiveEntity === false)).toEqual([]);
430
+ }, 300_000);
431
+ });
432
+
433
+ // ── Findings about what the product scaffolds / previews ──
434
+ describe('scaffold and preview defaults', () => {
435
+ it('F-1: the UI5 version fiori_scaffold_fe writes by default is hosted on the UI5 CDN', async () => {
436
+ const dir = path.join(root, 'default-version');
437
+ await mkdir(dir, { recursive: true });
438
+ const app = await buildGoodApp(dir);
439
+ const manifest = JSON.parse(await readFile(path.join(app.dir, 'webapp', 'manifest.json'), 'utf8'));
440
+ const v: string = manifest['sap.ui5'].dependencies.minUI5Version;
441
+ expect(isHosted(v, neoApp), `scaffold default UI5 ${v} is not on ui5.sap.com (harness pins ${HARNESS_UI5_VERSIONS.newest})`).toBe(
442
+ true,
443
+ );
444
+ }, 120_000);
445
+
446
+ it.todo(
447
+ `runs on the oldest supported UI5 too (${HARNESS_UI5_VERSIONS.oldest}) — wired: CSP_HARNESS_UI5=both; not in the default run until the owner names the oldest supported release`,
448
+ );
449
+
450
+ it("F-7: fiori_preview's default entry for a scaffolded app is the page npm start opens (FLP sandbox)", async () => {
451
+ const pkg = JSON.parse(await readFile(path.join(good.app.dir, 'package.json'), 'utf8'));
452
+ expect('/' + defaultEntryFile(pkg.scripts.start)).toBe(flpEntry(good.app));
453
+ });
454
+
455
+ it('F-7: on that entry, pressing Go raises no console or page error', async () => {
456
+ const r = await smoke(good, [{ kind: 'press', selectorHint: GO }], 'shots-flp-go');
457
+ const ex = check(r, 'exercises');
458
+ expect(ex?.ok, ex?.detail).toBe(true);
459
+ }, 240_000);
460
+ });
461
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The harness serves manifest.json the way `fiori run` does (2026-09-25): UI5
3
+ * tooling 4's dev server passes every app manifest through the builder's
4
+ * manifestEnhancer, which adds supportedLocales from the i18n files the app
5
+ * ships. Serving the raw file hid a boot error every real preview shows.
6
+ */
7
+ import { describe, it, expect, afterEach } from 'vitest';
8
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import { enhancedManifest } from './server.js';
12
+
13
+ const dirs: string[] = [];
14
+ afterEach(async () => {
15
+ while (dirs.length > 0) await rm(dirs.pop()!, { recursive: true, force: true });
16
+ });
17
+
18
+ async function webapp(i18n: unknown, files: string[] = ['i18n.properties']): Promise<string> {
19
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'csp-harness-manifest-'));
20
+ dirs.push(dir);
21
+ await mkdir(path.join(dir, 'i18n'));
22
+ for (const f of files) await writeFile(path.join(dir, 'i18n', f), 'appTitle=Test\n');
23
+ const manifest = {
24
+ _version: '1.60.0',
25
+ 'sap.app': { id: 'z.harness.test', type: 'application', i18n },
26
+ 'sap.ui5': {
27
+ models: { i18n: { type: 'sap.ui.model.resource.ResourceModel', settings: { bundleName: 'z.harness.test.i18n.i18n' } } },
28
+ },
29
+ };
30
+ await writeFile(path.join(dir, 'manifest.json'), JSON.stringify(manifest));
31
+ return dir;
32
+ }
33
+
34
+ describe('enhancedManifest — what `fiori run` serves', () => {
35
+ it('adds the locales the app ships and NO fallback (the case UI5 then logs at boot)', async () => {
36
+ const served = JSON.parse(await enhancedManifest(await webapp('i18n/i18n.properties')));
37
+ expect(served['sap.app'].i18n).toEqual({ bundleUrl: 'i18n/i18n.properties', supportedLocales: [''] });
38
+ expect(served['sap.ui5'].models.i18n.settings.supportedLocales).toEqual(['']);
39
+ });
40
+
41
+ it('keeps an explicitly declared fallback locale', async () => {
42
+ const dir = await webapp({ bundleUrl: 'i18n/i18n.properties', supportedLocales: [''], fallbackLocale: '' });
43
+ const served = JSON.parse(await enhancedManifest(dir));
44
+ expect(served['sap.app'].i18n).toEqual({ bundleUrl: 'i18n/i18n.properties', supportedLocales: [''], fallbackLocale: '' });
45
+ });
46
+
47
+ it('lists every locale file the app ships', async () => {
48
+ const served = JSON.parse(await enhancedManifest(await webapp('i18n/i18n.properties', ['i18n.properties', 'i18n_en.properties', 'i18n_de.properties'])));
49
+ expect([...served['sap.app'].i18n.supportedLocales].sort()).toEqual(['', 'de', 'en']);
50
+ });
51
+ });