@cspeach/cli 1.1.47 → 1.1.49

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 (110) hide show
  1. package/dist/agent/level.js +1 -1
  2. package/dist/agent/loop.js +7 -6
  3. package/dist/agent/sap-connection-adapter.js +2 -0
  4. package/dist/agent/system-prompt.js +2 -2
  5. package/dist/agent/tool-dispatch.js +13 -1
  6. package/dist/approvals/op-labels.js +49 -10
  7. package/dist/approvals/render.js +14 -23
  8. package/dist/approvals/risk-floor.js +196 -19
  9. package/dist/approvals/visible-changes.js +35 -0
  10. package/dist/commands/config-set.js +5 -5
  11. package/dist/commands/cost.js +2 -1
  12. package/dist/commands/help.js +1 -1
  13. package/dist/commands/project.js +301 -0
  14. package/dist/commands/team-plan.js +16 -6
  15. package/dist/commands/team.js +295 -68
  16. package/dist/config/loader.js +13 -0
  17. package/dist/cost/cost-log.js +7 -4
  18. package/dist/cost/credits-wire.js +11 -6
  19. package/dist/cost/credits.js +15 -0
  20. package/dist/cost/display-mode.js +16 -4
  21. package/dist/doctor/checks/zcspeach.js +96 -0
  22. package/dist/journey/chain.js +489 -61
  23. package/dist/journey/clean-core.js +61 -36
  24. package/dist/journey/flow.js +75 -2
  25. package/dist/journey/people.js +37 -0
  26. package/dist/journey/rebuild.js +7 -6
  27. package/dist/journey/run.js +129 -42
  28. package/dist/journey/trigger.js +16 -10
  29. package/dist/one-shot.js +1 -1
  30. package/dist/project-context/render.js +7 -0
  31. package/dist/projects/clean-core-confirm.js +166 -44
  32. package/dist/projects/email-template.js +56 -0
  33. package/dist/projects/extract-cca.js +8 -7
  34. package/dist/projects/index.js +0 -1
  35. package/dist/projects/merge-cca.js +0 -19
  36. package/dist/projects/merge-upgrade.js +2 -2
  37. package/dist/projects/project-folder.js +2 -1
  38. package/dist/projects/save-command.js +51 -14
  39. package/dist/register/decision-rules.js +2 -1
  40. package/dist/register/export-files.js +18 -0
  41. package/dist/register/picture.js +3 -3
  42. package/dist/register/project-transport.js +125 -0
  43. package/dist/register/records.js +25 -13
  44. package/dist/register/rules-edit.js +5 -0
  45. package/dist/register/status-file/progress-chart.js +17 -6
  46. package/dist/register/status-file/register-map.js +8 -5
  47. package/dist/register/status-file/register-model.js +55 -36
  48. package/dist/register/status-file/register-parts.js +97 -82
  49. package/dist/register/status-file/register-status-file.js +16 -7
  50. package/dist/register/status-file/report-model.js +293 -205
  51. package/dist/register/status-file/team-css.js +1 -1
  52. package/dist/register/status-html.js +11 -4
  53. package/dist/register/store.js +2 -2
  54. package/dist/register/workbooks.js +24 -13
  55. package/dist/renderer/footer-line.js +10 -2
  56. package/dist/renderer/status-footer.js +2 -1
  57. package/dist/renderer/tool-labels.js +2 -2
  58. package/dist/repl/builtin-commands.js +1 -1
  59. package/dist/repl/continue-skill.js +5 -0
  60. package/dist/repl/level-command.js +6 -15
  61. package/dist/repl.js +21 -8
  62. package/dist/router/router-core.js +5 -3
  63. package/dist/session/audit-export.js +4 -1
  64. package/dist/skill-catalog.js +4 -7
  65. package/dist/tools/_team-work-path.js +53 -0
  66. package/dist/tools/approval.js +97 -19
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-write.js +8 -1
  69. package/dist/tools/local-files.js +31 -0
  70. package/dist/tools/project/_merge-shared.js +2 -2
  71. package/dist/tools/project/_team-tool-shared.js +5 -0
  72. package/dist/tools/project/cca_merge.js +1 -11
  73. package/dist/tools/project/rules_propose.js +2 -1
  74. package/dist/tools/project/team_findings.js +81 -28
  75. package/dist/tools/project/team_lane_move.js +127 -0
  76. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  77. package/dist/tools/subagent/schedule_create.js +131 -0
  78. package/dist/tools/text-elements/tools.js +7 -1
  79. package/dist/tools/tool-loading.js +1 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +19 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/error-emitter.js +21 -0
  84. package/dist/ui/footer-line.js +19 -1
  85. package/dist/ui/header.js +41 -0
  86. package/dist/ui/ink-classifier-route.js +19 -0
  87. package/dist/ui/rich-error-box.js +9 -0
  88. package/dist/ui/sidebar.js +12 -0
  89. package/dist/ui/status-emitter.js +19 -0
  90. package/dist/ui/status-line.js +62 -0
  91. package/dist/ui/status-row.js +38 -0
  92. package/dist/ui/todo-panel.js +85 -0
  93. package/dist/ui/widgets/ask-form.js +250 -0
  94. package/dist/ui/widgets/ask-question-modal.js +178 -0
  95. package/fiori/zlas-inspection-lr/package.json +26 -0
  96. package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
  97. package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
  98. package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
  99. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
  100. package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
  101. package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
  102. package/live-check-2026-10-17-1343.txt +41 -0
  103. package/package.json +121 -121
  104. package/test-harness/fe-draft/app.ts +0 -188
  105. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  106. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  107. package/test-harness/fe-draft/routes.ts +0 -224
  108. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  109. package/test-harness/fe-draft/server.ts +0 -225
  110. package/test-harness/fe-draft/variants.ts +0 -196
@@ -1,188 +0,0 @@
1
- // cspeach-cli/test-harness/fe-draft/app.ts
2
- //
3
- // L1-2b — the apps the offline harness serves, built with the PRODUCT's own
4
- // tools (fiori_scaffold_fe, fiori_fe_extend handlers) so a scaffold regression
5
- // shows up here, not in a sitting.
6
- //
7
- // good — an LROP app over the captured draft service, exactly as
8
- // fiori_scaffold_fe writes it, plus one harmless custom table
9
- // action ("Ping") so the good app has something to press.
10
- // broken — the same app whose custom table action ("Boom") is wired to a
11
- // handler that THROWS. A smoke that runs the press exercise must
12
- // fail `exercises` on it.
13
- // brokenPress — the same app with the custom action exactly as
14
- // fiori_fe_extend writes it when asked for `eventHandler: true`:
15
- // `"press": ""`. Pressing it makes UI5 try to load "/.js".
16
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
17
- import * as path from 'node:path';
18
- import { fileURLToPath } from 'node:url';
19
- import { fioriScaffoldFeHandler, feExtendHandler } from '../../src/tools/fiori/tools.js';
20
- import type { ToolContext } from '../../src/tools/index.js';
21
- import { openUrlFromStartScript } from './routes.js';
22
-
23
- const HERE = path.dirname(fileURLToPath(import.meta.url));
24
-
25
- /** The captured draft service (ZUI_LAS_INSPECTIONLOG_O4) — read in place by the mockserver. */
26
- export const DRAFT_METADATA = path.resolve(
27
- HERE,
28
- '../../src/tools/fiori/__tests__/fixtures/metadata/v4-draft-main.xml',
29
- );
30
- export const DRAFT_SERVICE_PATH =
31
- '/sap/opu/odata4/sap/zui_las_inspectionlog_o4/srvd/sap/zui_las_inspectionlog_o4/0001/';
32
- export const DRAFT_MAIN_ENTITY = 'InspectionLog';
33
- /** Fully-qualified draft actions, as `SAP__common.DraftRoot` names them in the capture. */
34
- export const DRAFT_NS = 'com.sap.gateway.srvd.zui_las_inspectionlog_o4.v0001';
35
-
36
- /** The action button id suffix FE V4 renders for a manifest custom action. */
37
- export const customActionSelector = (name: string): string => `[id$="::CustomAction::${name}"]`;
38
-
39
- /**
40
- * Fixed active rows for the draft root. Invented values, US sample names; user
41
- * fields are MJOHNSON (the fixture convention). Status / Severity are the raw
42
- * one-letter codes the captured service really carries with no text — the shape
43
- * L1-3's no-raw-codes check has to catch.
44
- */
45
- const row = (n: number, equipment: string, inspector: string, severity: string, status: string) => ({
46
- InspectionUUID: `00000000-0000-4000-8000-00000000000${n}`,
47
- EquipmentName: equipment,
48
- InspectionDate: '2026-09-0' + n,
49
- Inspector: inspector,
50
- Severity: severity,
51
- SeverityCriticality: severity === 'H' ? 1 : severity === 'M' ? 2 : 3,
52
- Status: status,
53
- Note: '',
54
- CreatedBy: 'MJOHNSON',
55
- LocalLastChangedBy: 'MJOHNSON',
56
- IsActiveEntity: true,
57
- HasActiveEntity: false,
58
- HasDraftEntity: false,
59
- });
60
- export const INSPECTION_ROWS = [
61
- row(1, 'Pump P-100', 'Mike Johnson', 'H', 'O'),
62
- row(2, 'Valve V-200', 'Sarah Davis', 'M', 'C'),
63
- row(3, 'Motor M-300', 'David Carter', 'L', 'O'),
64
- ];
65
- export const HARNESS_MOCK_ROWS = { [DRAFT_MAIN_ENTITY]: INSPECTION_ROWS };
66
-
67
- export const BROKEN_MARKER = 'CSP-HARNESS-BROKEN: the Boom handler threw';
68
-
69
- function ctxFor(root: string): ToolContext {
70
- return {
71
- adt: {} as ToolContext['adt'],
72
- sapAlias: 'HARNESS',
73
- session: {} as ToolContext['session'],
74
- cwd: root,
75
- };
76
- }
77
-
78
- async function must(r: { content: string; is_error?: boolean }, what: string): Promise<void> {
79
- if (r.is_error) throw new Error(`${what} failed: ${r.content}`);
80
- }
81
-
82
- export interface HarnessApp {
83
- /** Absolute app dir (holds webapp/). */
84
- dir: string;
85
- appId: string;
86
- /** The page the scaffold's `npm start` opens (FLP sandbox + intent), app-relative. */
87
- startEntry: string;
88
- }
89
-
90
- /** The FLP sandbox entry `npm start` opens for this app. */
91
- export const flpEntry = (app: HarnessApp): string => app.startEntry;
92
-
93
- /** Scaffold the LROP app through the fiori_scaffold_fe handler. `ui5Version` undefined = the product default. */
94
- async function scaffold(root: string, rel: string, appId: string, ui5Version?: string): Promise<HarnessApp> {
95
- await must(
96
- await fioriScaffoldFeHandler(
97
- {
98
- basePath: rel,
99
- appId,
100
- appTitle: 'Inspection Log (harness)',
101
- template: 'lrop',
102
- serviceUrl: 'http://localhost',
103
- servicePath: DRAFT_SERVICE_PATH,
104
- mainEntity: DRAFT_MAIN_ENTITY,
105
- ...(ui5Version ? { ui5Version } : {}),
106
- },
107
- ctxFor(root),
108
- ),
109
- 'fiori_scaffold_fe',
110
- );
111
- const dir = path.join(root, rel);
112
- const pkg = JSON.parse(await readFile(path.join(dir, 'package.json'), 'utf8'));
113
- return { dir, appId, startEntry: openUrlFromStartScript(pkg.scripts.start) };
114
- }
115
-
116
- /** Add a custom table action through the fiori_fe_extend handler. */
117
- async function addTableAction(root: string, rel: string, name: string, settings: Record<string, unknown>): Promise<void> {
118
- await must(
119
- await feExtendHandler(
120
- {
121
- basePath: rel,
122
- op: 'custom-action',
123
- params: {
124
- name,
125
- target: { page: `${DRAFT_MAIN_ENTITY}List`, control: '@com.sap.vocabularies.UI.v1.LineItem' },
126
- settings: { text: name, ...settings },
127
- },
128
- },
129
- ctxFor(root),
130
- ),
131
- 'fiori_fe_extend custom-action',
132
- );
133
- }
134
-
135
- /**
136
- * Point a custom action's `press` at `ext/<Name>.js#onPress` and write that
137
- * module with a known body (the good app's harmless handler, the broken app's
138
- * throwing one), replacing the stub fiori_fe_extend generated.
139
- */
140
- async function wireHandler(app: HarnessApp, name: string, body: string): Promise<void> {
141
- const manifestPath = path.join(app.dir, 'webapp', 'manifest.json');
142
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
143
- const action =
144
- manifest['sap.ui5'].routing.targets[`${DRAFT_MAIN_ENTITY}List`].options.settings.controlConfiguration[
145
- '@com.sap.vocabularies.UI.v1.LineItem'
146
- ].actions[name];
147
- action.press = `${app.appId}.ext.${name}.onPress`;
148
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
149
- await mkdir(path.join(app.dir, 'webapp', 'ext'), { recursive: true });
150
- await writeFile(
151
- path.join(app.dir, 'webapp', 'ext', `${name}.js`),
152
- `sap.ui.define([], function () {\n 'use strict';\n return { onPress: function () { ${body} } };\n});\n`,
153
- );
154
- }
155
-
156
- /** The good app: scaffold as the product writes it, plus a harmless "Ping" action. */
157
- export async function buildGoodApp(root: string, ui5Version?: string): Promise<HarnessApp> {
158
- const app = await scaffold(root, 'good', 'z.harness.good', ui5Version);
159
- await addTableAction(root, 'good', 'Ping', {});
160
- await wireHandler(app, 'Ping', 'return;');
161
- return app;
162
- }
163
-
164
- /** The broken app: the "Boom" action's handler throws. */
165
- export async function buildBrokenApp(root: string, ui5Version?: string): Promise<HarnessApp> {
166
- const app = await scaffold(root, 'broken', 'z.harness.broken', ui5Version);
167
- await addTableAction(root, 'broken', 'Boom', {});
168
- await wireHandler(app, 'Boom', `throw new Error(${JSON.stringify(BROKEN_MARKER)});`);
169
- return app;
170
- }
171
-
172
- /**
173
- * The broken-press app: a custom action whose press names a handler module that
174
- * does not exist (a renamed or deleted ext file). fiori_fe_extend no longer
175
- * writes an empty press — it always generates a handler — so the realistic
176
- * broken case is a dangling reference.
177
- */
178
- export async function buildBrokenPressApp(root: string, ui5Version?: string): Promise<HarnessApp> {
179
- const app = await scaffold(root, 'broken-press', 'z.harness.brokenpress', ui5Version);
180
- await addTableAction(root, 'broken-press', 'Boom', {});
181
- const manifestPath = path.join(app.dir, 'webapp', 'manifest.json');
182
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
183
- manifest['sap.ui5'].routing.targets[`${DRAFT_MAIN_ENTITY}List`].options.settings.controlConfiguration[
184
- '@com.sap.vocabularies.UI.v1.LineItem'
185
- ].actions.Boom.press = `${app.appId}.ext.missing.Missing.onPress`;
186
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
187
- return app;
188
- }
@@ -1,461 +0,0 @@
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
- });