@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,473 @@
1
+ // cspeach-cli/src/tools/fiori/smoke/draft-smoke.ts
2
+ //
3
+ // L1-5b — the draft round trip, run through the app (design §3.3,
4
+ // "draft-roundtrip, in full"; review F-3 / F-7).
5
+ //
6
+ // A greenfield table is empty, so the smoke brings its own row:
7
+ // 0. preconditions (draftSmokePreconditions) — BEFORE any network call;
8
+ // 1. sweep: leftover CSP-SMOKE-* drafts, then rows, deleted (marker-checked);
9
+ // 2. setup BY API inside the page: POST a draft carrying CSP-SMOKE-<runId>,
10
+ // Prepare (if the audit names one), then the ACTIVATION action named by
11
+ // Common.DraftRoot;
12
+ // 3. rows — the list (after Go) shows the new row;
13
+ // 4. save leg BY UI: open → Edit → change one field through its value help
14
+ // (the value-help check) → Save (= Prepare + Activate) → the backend has
15
+ // the value, the list shows its TEXT;
16
+ // 5. discard leg BY UI: open → Edit → change → Discard → the saved value is
17
+ // back, no draft left;
18
+ // 6. odata — the app's inner $batch statuses over the whole session;
19
+ // 7. cleanup in `finally`: our draft and our row, each deleted only after it
20
+ // is re-read and still carries OUR marker.
21
+ // All UI goes through the AppDriver (RecordReplay in lane 1). The browser and
22
+ // the driver are injectable; the unit suite drives every path with fakes.
23
+ import { mkdirSync } from 'node:fs';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import { resolveBrowserExecutable } from './browser.js';
27
+ import { openApp as realOpenApp } from './driver.js';
28
+ import { writeEvidence, auditSummary, SMOKE_VIEWPORT } from './evidence.js';
29
+ import { checkOData, normaliseHeader } from './assertions.js';
30
+ import { createRecordReplayDriver } from './app-driver.js';
31
+ import { innerBatchParts, SMOKE_SELECTORS } from './run-smoke.js';
32
+ import { deriveExpectedColumns } from './audit-columns.js';
33
+ import { cellShowsCode } from './labels-codes.js';
34
+ import { customerService, isLoopbackAppUrl, MARKER_PREFIX, newRunId, isMarked, selectSweepTargets, keyPredicate, markerFieldProblem, draftActionsProblem, buildSetupPayload, pickEditField, capService } from './draft-safety.js';
35
+ const DEFAULT_TIMEOUT_MS = 90_000;
36
+ const LOGON_URL = /sap-system-login|\/saml2\/|\/logon|\/login/i;
37
+ /** Everything that must hold before any network call. Pure. `undefined` = go. */
38
+ export function draftSmokePreconditions(spec, marker = `${MARKER_PREFIX}00000000000000-0000`) {
39
+ const audit = spec.metadataAudit;
40
+ if (!audit || typeof audit !== 'object' || !Array.isArray(audit.properties)) {
41
+ return 'metadataAudit is required — pass the fiori_metadata_audit result for the draft root as-is';
42
+ }
43
+ const svc = spec.backend === 'cap' ? capService(audit.servicePath) : customerService(audit.servicePath);
44
+ if (!svc.ok)
45
+ return svc.reason;
46
+ if (!isLoopbackAppUrl(spec.appUrl)) {
47
+ return (`appUrl "${spec.appUrl}" is not a local preview (http(s) on localhost / 127.x / [::1]) — ` +
48
+ 'fiori_draft_smoke runs only against the preview it is given, never a deployed app');
49
+ }
50
+ const draft = draftActionsProblem(audit);
51
+ if (draft)
52
+ return draft;
53
+ const mk = markerFieldProblem(audit, spec.markerField, marker);
54
+ if (mk)
55
+ return mk;
56
+ if (!audit.properties.some((p) => p.isKey))
57
+ return `${audit.entitySet} has no key properties`;
58
+ const edit = pickEditField(audit, spec.editField, spec.markerField);
59
+ if ('error' in edit)
60
+ return edit.error;
61
+ return undefined;
62
+ }
63
+ function manual(detail) {
64
+ return { verification: 'manual', passed: false, checks: [{ name: 'skipped', ok: false, detail }] };
65
+ }
66
+ function messageOf(r) {
67
+ const b = r.body;
68
+ const m = b?.error?.message;
69
+ if (typeof m === 'string')
70
+ return m;
71
+ if (m && typeof m === 'object' && typeof m.value === 'string')
72
+ return m.value;
73
+ return r.text || '';
74
+ }
75
+ const ok2xx = (s) => s >= 200 && s < 300;
76
+ class StepError extends Error {
77
+ }
78
+ /**
79
+ * The value to set through the value help: the LOWEST key that differs from
80
+ * `current`. The list order differs between UI5 releases (1.96/1.108 vs 1.120),
81
+ * so "the first one that differs" was not the same pick everywhere.
82
+ */
83
+ export function chooseDifferentKey(values, current) {
84
+ return values
85
+ .map((v) => v.key)
86
+ .filter((k) => k !== current)
87
+ .sort((a, b) => a.localeCompare(b))[0];
88
+ }
89
+ export async function runDraftSmoke(spec, deps = {}) {
90
+ const runId = deps.runId ?? newRunId(deps.now?.() ?? new Date());
91
+ const marker = `${MARKER_PREFIX}${runId}`;
92
+ // Defence in depth: the handler checked these already; nothing below runs without them.
93
+ const pre = draftSmokePreconditions(spec, marker);
94
+ if (pre)
95
+ return manual(`refused before any network call: ${pre}`);
96
+ const audit = spec.metadataAudit;
97
+ const svc = spec.backend === 'cap' ? capService(audit.servicePath) : customerService(audit.servicePath);
98
+ if (!svc.ok)
99
+ return manual(svc.reason); // unreachable (preconditions)
100
+ const root = svc.serviceRoot;
101
+ const set = audit.entitySet;
102
+ const setUrl = `${root}${set}`;
103
+ const keys = audit.properties.filter((p) => p.isKey);
104
+ const edit = pickEditField(audit, spec.editField, spec.markerField).property;
105
+ const vh = edit.valueHelp;
106
+ const actions = audit.draft.actions;
107
+ const browser = (deps.resolveBrowser ?? (() => resolveBrowserExecutable()))();
108
+ if (!browser)
109
+ return manual('no Chromium-based browser found (set CSPEACH_BROWSER to an Edge/Chrome executable)');
110
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
111
+ let page;
112
+ try {
113
+ page = await (deps.openApp ?? realOpenApp)(browser.path, spec.appUrl, timeoutMs, timeoutMs, {
114
+ headless: spec.headless ?? true,
115
+ slowMo: spec.slowMo ?? 0,
116
+ ...(spec.capUser ? { credentials: { username: spec.capUser, password: '' } } : {}),
117
+ });
118
+ }
119
+ catch {
120
+ return manual(`preview server not reachable at ${spec.appUrl}; start it with fiori_preview first — nothing was written`);
121
+ }
122
+ const startedAt = new Date();
123
+ const t0 = Date.now();
124
+ const checks = [];
125
+ const errorsSeen = [];
126
+ const leftovers = [];
127
+ const screenshots = {};
128
+ let shotDir;
129
+ const capture = async (key, file) => {
130
+ if (!page.screenshot)
131
+ return;
132
+ try {
133
+ shotDir ??= deps.artifactDir ?? path.join(os.homedir(), '.cspeach', 'smoke', `draft-${runId}`);
134
+ mkdirSync(shotDir, { recursive: true });
135
+ const target = path.join(shotDir, file);
136
+ await page.screenshot(target);
137
+ screenshots[key] = target;
138
+ }
139
+ catch {
140
+ /* evidence is best-effort */
141
+ }
142
+ };
143
+ // What this run created — recorded the moment it exists, for the cleanup.
144
+ let createdDraft;
145
+ let createdActive;
146
+ let logon = false;
147
+ try {
148
+ const logonHits = await page.queryTexts(SMOKE_SELECTORS.logon);
149
+ logon = LOGON_URL.test(page.currentUrl()) || logonHits.length > 0;
150
+ }
151
+ catch {
152
+ /* judged by the steps below */
153
+ }
154
+ if (logon) {
155
+ const at = page.currentUrl();
156
+ await page.close();
157
+ return manual(`preview served a SAP logon page at ${at} — nothing was written`);
158
+ }
159
+ const makeDriver = deps.makeDriver ??
160
+ ((p) => {
161
+ const evalInPage = p.evalInPage;
162
+ if (!evalInPage)
163
+ throw new Error('the page driver cannot run code in the page');
164
+ return createRecordReplayDriver({
165
+ evalInPage: (src, arg) => evalInPage.call(p, src, arg),
166
+ });
167
+ });
168
+ let driver;
169
+ try {
170
+ driver = makeDriver(page);
171
+ }
172
+ catch (e) {
173
+ await page.close();
174
+ return manual(`${e instanceof Error ? e.message : String(e)} — nothing was written`);
175
+ }
176
+ // DELETE and bound actions carry If-Match: * (a RAP etag master needs one);
177
+ // the create POST on the entity set does not.
178
+ const req = (method, url, body) => driver.request({
179
+ method,
180
+ serviceRoot: root,
181
+ url,
182
+ ...(body !== undefined ? { body } : {}),
183
+ ...(method === 'DELETE' || (method === 'POST' && url !== setUrl) ? { ifMatch: '*' } : {}),
184
+ });
185
+ const pred = (row) => keyPredicate(keys, row);
186
+ const asDraft = (row) => ({ ...row, IsActiveEntity: false });
187
+ const noteErrors = async (step) => {
188
+ const errs = await driver.openErrors();
189
+ for (const e of errs)
190
+ errorsSeen.push(`${step}: ${e}`);
191
+ return errs;
192
+ };
193
+ const selectList = [...keys.map((k) => k.name), spec.markerField].join(',');
194
+ const readValue = async (row) => {
195
+ const textPath = edit.text && !edit.text.path.includes('/') ? edit.text.path : undefined;
196
+ const hasDraft = audit.properties.some((p) => p.name === 'HasDraftEntity');
197
+ const sel = [edit.name, ...(textPath ? [textPath] : []), ...(hasDraft ? ['HasDraftEntity'] : [])].join(',');
198
+ const r = await req('GET', `${setUrl}${pred(row)}?$select=${sel}`);
199
+ const b = (r.body ?? {});
200
+ return { status: r.status, value: b[edit.name], ...(textPath ? { text: b[textPath] } : {}), ...(hasDraft ? { hasDraft: b.HasDraftEntity } : {}) };
201
+ };
202
+ let step = 'sweep';
203
+ try {
204
+ await driver.waitForUI5();
205
+ // ── 1. sweep ──
206
+ const removed = [];
207
+ const failedSweep = [];
208
+ for (const active of [false, true]) {
209
+ const filter = `startswith(${spec.markerField},'${MARKER_PREFIX}') and IsActiveEntity eq ${active}`;
210
+ const r = await req('GET', `${setUrl}?$filter=${encodeURIComponent(filter)}&$select=${selectList}`);
211
+ if (!ok2xx(r.status))
212
+ throw new StepError(`reading leftover ${active ? 'rows' : 'drafts'} answered ${r.status}: ${messageOf(r)}`);
213
+ const rows = (r.body?.value ?? []);
214
+ // Neither half of the server's filter is trusted: draft-ness and the marker are re-checked here.
215
+ for (const row of selectSweepTargets(rows.filter((x) => x.IsActiveEntity === active), spec.markerField)) {
216
+ const d = await req('DELETE', `${setUrl}${pred(row)}`);
217
+ if (ok2xx(d.status) || d.status === 404)
218
+ removed.push(`${String(row[spec.markerField])}${active ? '' : ' (draft)'}`);
219
+ else
220
+ failedSweep.push(`${pred(row)} → ${d.status} ${messageOf(d)}`);
221
+ }
222
+ }
223
+ checks.push({
224
+ name: 'sweep',
225
+ ok: failedSweep.length === 0,
226
+ detail: failedSweep.length > 0
227
+ ? `could not remove leftover ${MARKER_PREFIX}* data: ${failedSweep.join('; ')}`
228
+ : removed.length > 0
229
+ ? `removed ${removed.length} leftover ${MARKER_PREFIX}* row(s)/draft(s): ${removed.join(', ')}`
230
+ : `no leftover ${MARKER_PREFIX}* rows or drafts`,
231
+ });
232
+ // ── 2. setup by API ──
233
+ step = 'setup';
234
+ const firstValues = {};
235
+ for (const p of audit.properties) {
236
+ if (!p.mandatory || p.isKey || p.computed || !p.valueHelp)
237
+ continue;
238
+ const vroot = p.valueHelp.resolvedPath ? p.valueHelp.resolvedPath.replace(/\$metadata$/, '') : root;
239
+ const r = await req('GET', `${vroot}${p.valueHelp.collectionPath}?$top=1&$select=${p.valueHelp.keyProperty}`);
240
+ const v = (r.body?.value ?? [])[0]?.[p.valueHelp.keyProperty];
241
+ if (ok2xx(r.status) && v !== undefined && v !== null)
242
+ firstValues[p.name] = String(v);
243
+ }
244
+ const built = buildSetupPayload(audit, spec.markerField, marker, firstValues, deps.now?.() ?? new Date());
245
+ if ('error' in built)
246
+ throw new StepError(built.error);
247
+ const created = await req('POST', setUrl, built.payload);
248
+ if (!ok2xx(created.status))
249
+ throw new StepError(`POST ${set} answered ${created.status}: ${messageOf(created)}`);
250
+ createdDraft = created.body;
251
+ if (actions.prepare) {
252
+ const p = await req('POST', `${setUrl}${pred(asDraft(createdDraft))}/${actions.prepare}`, {});
253
+ if (!ok2xx(p.status))
254
+ throw new StepError(`${actions.prepare} answered ${p.status}: ${messageOf(p)}`);
255
+ }
256
+ const act = await req('POST', `${setUrl}${pred(asDraft(createdDraft))}/${actions.activate}`, {});
257
+ if (!ok2xx(act.status))
258
+ throw new StepError(`${actions.activate} answered ${act.status}: ${messageOf(act)}`);
259
+ createdActive = { ...act.body, IsActiveEntity: true };
260
+ checks.push({ name: 'setup', ok: true, detail: `created ${set}${pred(createdActive)} carrying ${marker} (activated with ${actions.activate})` });
261
+ // ── 3. rows — after setup, so an empty greenfield table passes honestly ──
262
+ step = 'rows';
263
+ const rowPath = `/${set}${pred(createdActive)}`;
264
+ await driver.pressAction('go');
265
+ const snap = await driver.cellTexts(set);
266
+ const ours = snap?.rows.find((r) => decodeURIComponent(r.path) === decodeURIComponent(rowPath));
267
+ if (!snap || !ours)
268
+ throw new StepError(`the list does not show the new row ${rowPath} (${snap ? snap.rows.length : 'no'} row(s) rendered)`);
269
+ checks.push({ name: 'rows', ok: true, detail: `${snap.rows.length} row(s) rendered, including the new ${marker} row` });
270
+ // ── 4. save leg ──
271
+ step = 'value-help';
272
+ const before = await readValue(createdActive);
273
+ await driver.openRow(rowPath);
274
+ await driver.pressAction('edit');
275
+ await noteErrors('edit');
276
+ const obs = await driver.setFieldViaValueHelp({ property: edit.name, keyProperty: vh.keyProperty, ...(vh.textProperty ? { textProperty: vh.textProperty } : {}) }, (vals) => chooseDifferentKey(vals, String(before.value ?? '')));
277
+ const vhOk = obs.open && obs.length >= 1;
278
+ checks.push({
279
+ name: 'value-help',
280
+ ok: vhOk,
281
+ detail: vhOk
282
+ ? `${edit.name}: the ${obs.container ?? 'value help'} opened with ${obs.length} entr${obs.length === 1 ? 'y' : 'ies'}`
283
+ : `${edit.name}: the value help ${obs.open ? `opened with ${obs.length} entries` : 'did not open'}`,
284
+ });
285
+ if (!vhOk)
286
+ throw new StepError('the value help did not offer a value');
287
+ step = 'draft-save';
288
+ if (!obs.picked)
289
+ throw new StepError(`the value help of ${edit.name} offers no value other than "${String(before.value)}"`);
290
+ const target = obs.picked;
291
+ const editErrs = await noteErrors('change');
292
+ await driver.pressAction('save');
293
+ const saveErrs = await noteErrors('save');
294
+ await capture('saveLeg', 'draft-save.png');
295
+ const after = await readValue(createdActive);
296
+ const saveProblems = [];
297
+ if (editErrs.length + saveErrs.length > 0)
298
+ saveProblems.push(`error shown: ${[...editErrs, ...saveErrs].join(' | ')}`);
299
+ if (String(after.value) !== target.key)
300
+ saveProblems.push(`backend has ${edit.name} = "${String(after.value)}", expected "${target.key}"`);
301
+ if (after.hasDraft === true)
302
+ saveProblems.push('a draft is still open after Save');
303
+ // The list shows the new TEXT (when the field is a list column).
304
+ const text = typeof after.text === 'string' && after.text ? after.text : target.text;
305
+ const column = deriveExpectedColumns(audit).find((c) => c.property === edit.name);
306
+ let listDetail = `${edit.name} is not a list column — list not compared`;
307
+ let onList = false;
308
+ if (saveProblems.length === 0 && column?.label) {
309
+ await driver.back();
310
+ onList = true;
311
+ await driver.pressAction('go');
312
+ const s2 = await driver.cellTexts(set);
313
+ const idx = s2 ? s2.columns.map(normaliseHeader).indexOf(normaliseHeader(column.label)) : -1;
314
+ const row2 = s2?.rows.find((r) => decodeURIComponent(r.path) === decodeURIComponent(rowPath));
315
+ const cell = idx >= 0 && row2 ? (row2.cells[idx] ?? '').trim() : undefined;
316
+ if (cell === undefined)
317
+ saveProblems.push(`the list no longer shows the row or its "${column.label}" column`);
318
+ else if (text && !cell.includes(text))
319
+ saveProblems.push(`the list cell shows "${cell}", not the text "${text}"`);
320
+ else if (text && cellShowsCode(cell, target.key, text))
321
+ saveProblems.push(`the list cell "${cell}" shows the raw code "${target.key}"`);
322
+ else if (!text && !cell.includes(target.key))
323
+ saveProblems.push(`the list cell shows "${cell}", not "${target.key}"`);
324
+ else
325
+ listDetail = `the list shows "${cell}"`;
326
+ }
327
+ checks.push({
328
+ name: 'draft-save',
329
+ ok: saveProblems.length === 0,
330
+ detail: saveProblems.length === 0
331
+ ? `Edit → ${edit.name} "${String(before.value)}" → "${target.key}" via value help → Save: activated; ${listDetail}`
332
+ : saveProblems.join('; '),
333
+ });
334
+ if (saveProblems.length > 0)
335
+ throw new StepError('the save leg failed');
336
+ // ── 5. discard leg ──
337
+ step = 'draft-discard';
338
+ // The discard leg opens the row from the LIST. The save leg only went back
339
+ // when the edited field is a list column (CAP live run: author_ID is not).
340
+ if (!onList) {
341
+ await driver.back();
342
+ await driver.pressAction('go');
343
+ }
344
+ await driver.openRow(rowPath);
345
+ await driver.pressAction('edit');
346
+ await noteErrors('edit');
347
+ const obs2 = await driver.setFieldViaValueHelp({ property: edit.name, keyProperty: vh.keyProperty, ...(vh.textProperty ? { textProperty: vh.textProperty } : {}) }, (vals) => chooseDifferentKey(vals, target.key));
348
+ if (!obs2.picked)
349
+ throw new StepError(`the value help of ${edit.name} offered nothing to change to`);
350
+ await driver.pressAction('discard');
351
+ const discardErrs = await noteErrors('discard');
352
+ await capture('discardLeg', 'draft-discard.png');
353
+ const final = await readValue(createdActive);
354
+ const draftLeft = await req('GET', `${setUrl}${pred(asDraft(createdActive))}?$select=${selectList}`);
355
+ const discardProblems = [];
356
+ if (discardErrs.length > 0)
357
+ discardProblems.push(`error shown: ${discardErrs.join(' | ')}`);
358
+ if (String(final.value) !== target.key) {
359
+ discardProblems.push(`backend has ${edit.name} = "${String(final.value)}" after Discard, expected the saved "${target.key}"`);
360
+ }
361
+ if (ok2xx(draftLeft.status))
362
+ discardProblems.push('the draft still exists after Discard');
363
+ checks.push({
364
+ name: 'draft-discard',
365
+ ok: discardProblems.length === 0,
366
+ detail: discardProblems.length === 0
367
+ ? `Edit → ${edit.name} "${target.key}" → "${obs2.picked.key}" → Discard: the saved "${target.key}" is back, no draft left`
368
+ : discardProblems.join('; '),
369
+ });
370
+ }
371
+ catch (err) {
372
+ const detail = err instanceof Error ? err.message : String(err);
373
+ // A step that already recorded its own failure is not recorded twice.
374
+ const existing = checks.find((c) => c.name === step);
375
+ if (!existing || existing.ok)
376
+ checks.push({ name: step, ok: false, detail });
377
+ }
378
+ finally {
379
+ for (const name of ['sweep', 'setup', 'rows', 'value-help', 'draft-save', 'draft-discard']) {
380
+ if (!checks.some((c) => c.name === name))
381
+ checks.push({ name, ok: false, detail: 'not run — an earlier step failed' });
382
+ }
383
+ // ── 7. cleanup — only OUR marker, re-read before every delete ──
384
+ const cleaned = [];
385
+ const candidates = [];
386
+ if (createdActive)
387
+ candidates.push(asDraft(createdActive), createdActive);
388
+ else if (createdDraft)
389
+ candidates.push(asDraft(createdDraft));
390
+ for (const row of candidates) {
391
+ let where;
392
+ try {
393
+ where = `${set}${pred(row)}`;
394
+ }
395
+ catch (e) {
396
+ leftovers.push(`a row created by this run could not be addressed: ${e instanceof Error ? e.message : String(e)}`);
397
+ continue;
398
+ }
399
+ try {
400
+ const cur = await req('GET', `${setUrl}${pred(row)}?$select=${selectList}`);
401
+ if (cur.status === 404)
402
+ continue;
403
+ const value = cur.body?.[spec.markerField];
404
+ if (!ok2xx(cur.status)) {
405
+ leftovers.push(`${where}: could not be read (${cur.status})`);
406
+ }
407
+ else if (!isMarked(value) || value !== marker) {
408
+ leftovers.push(`${where}: NOT deleted — its ${spec.markerField} is "${String(value)}", not ${marker}`);
409
+ }
410
+ else {
411
+ const d = await req('DELETE', `${setUrl}${pred(row)}`);
412
+ if (ok2xx(d.status) || d.status === 404)
413
+ cleaned.push(where);
414
+ else
415
+ leftovers.push(`${where}: DELETE answered ${d.status} ${messageOf(d)}`);
416
+ }
417
+ }
418
+ catch (e) {
419
+ leftovers.push(`${where}: ${e instanceof Error ? e.message : String(e)}`);
420
+ }
421
+ }
422
+ checks.push({
423
+ name: 'cleanup',
424
+ ok: leftovers.length === 0,
425
+ detail: leftovers.length > 0
426
+ ? `left behind: ${leftovers.join('; ')}`
427
+ : cleaned.length > 0
428
+ ? `deleted ${cleaned.join(', ')} (${marker})`
429
+ : 'nothing to delete — this run created no row',
430
+ });
431
+ checks.push({
432
+ name: 'no-error-dialogs',
433
+ ok: errorsSeen.length === 0,
434
+ detail: errorsSeen.length === 0 ? 'no error dialog or error message was shown' : errorsSeen.join('; '),
435
+ });
436
+ try {
437
+ checks.push(checkOData(page.failedRequests(), page.observedRequests ? page.observedRequests() : [], await innerBatchParts(page), page.unansweredRequests ? page.unansweredRequests() : []));
438
+ }
439
+ catch (e) {
440
+ checks.push({ name: 'odata', ok: false, detail: `could not judge: ${e instanceof Error ? e.message : String(e)}` });
441
+ }
442
+ if (checks.some((c) => !c.ok))
443
+ await capture('failure', 'failure.png');
444
+ await page.close();
445
+ }
446
+ const result = {
447
+ verification: 'smoke',
448
+ passed: checks.every((c) => c.ok),
449
+ checks,
450
+ marker,
451
+ ...(leftovers.length > 0 ? { leftovers } : {}),
452
+ ...(Object.keys(screenshots).length > 0 ? { screenshots } : {}),
453
+ };
454
+ // L1-8b evidence pack — local file beside the step screenshots.
455
+ const evidence = writeEvidence(shotDir ?? deps.artifactDir ?? path.join(os.homedir(), '.cspeach', 'smoke', `draft-${runId}`), {
456
+ tool: 'fiori_draft_smoke',
457
+ appUrl: spec.appUrl,
458
+ startedAt: startedAt.toISOString(),
459
+ finishedAt: new Date().toISOString(),
460
+ viewport: SMOKE_VIEWPORT,
461
+ marker,
462
+ verification: result.verification,
463
+ passed: result.passed,
464
+ checks: result.checks,
465
+ leftovers,
466
+ audit: auditSummary(audit),
467
+ screenshots,
468
+ timings: { totalMs: Date.now() - t0 },
469
+ });
470
+ if (evidence)
471
+ result.evidence = evidence;
472
+ return result;
473
+ }