@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.
- package/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +80 -22
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/signing-public-key.js +1 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
// leg" trap). This driver deliberately stays neutral: it exposes failedRequests()
|
|
16
16
|
// + consoleErrors() precisely so Task 9 can detect the logon-page / unreachable
|
|
17
17
|
// cases from the collected signals. Do not add any deployed-URL assumption here.
|
|
18
|
-
import
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
/** L1-5a: a `$batch` URL (the only kind whose bodies the driver records). */
|
|
21
|
+
export const BATCH_URL = /\/\$batch(\?|$)/;
|
|
19
22
|
/**
|
|
20
23
|
* Launch a headless browser at `executablePath`, navigate to `url`, and return a
|
|
21
24
|
* SmokePage that has already begun collecting failed requests + console errors.
|
|
@@ -24,33 +27,197 @@ import puppeteer from 'puppeteer-core';
|
|
|
24
27
|
* calls. Never called from unit tests (no real browser in CI) — exercised only
|
|
25
28
|
* on the live leg (VPN, local authenticated preview up).
|
|
26
29
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
/**
|
|
31
|
+
* L1-3 in-page script for `cellTextsByColumn`: find the sap.m.Table /
|
|
32
|
+
* sap.ui.table.Table whose rows are bound to `/<entitySet>` through UI5's
|
|
33
|
+
* Element registry, then read headers, row paths and cell texts.
|
|
34
|
+
*/
|
|
35
|
+
export const TABLE_SNAPSHOT_SRC = String.raw `async function (entitySet) {
|
|
36
|
+
var Element = (sap.ui.require && sap.ui.require('sap/ui/core/Element')) || (sap.ui.core && sap.ui.core.Element);
|
|
37
|
+
if (!Element || !Element.registry) return null;
|
|
38
|
+
var all = Element.registry.all();
|
|
39
|
+
var table = null;
|
|
40
|
+
for (var id in all) {
|
|
41
|
+
var c = all[id];
|
|
42
|
+
if (!c || !c.isA) continue;
|
|
43
|
+
var isM = c.isA('sap.m.Table');
|
|
44
|
+
var isGrid = c.isA('sap.ui.table.Table');
|
|
45
|
+
if (!isM && !isGrid) continue;
|
|
46
|
+
var b = c.getBinding(isM ? 'items' : 'rows');
|
|
47
|
+
var bp = b && b.getPath ? String(b.getPath()) : '';
|
|
48
|
+
if (bp.replace(/^\//, '') === entitySet) { table = c; break; }
|
|
49
|
+
}
|
|
50
|
+
if (!table) return null;
|
|
51
|
+
var isMTable = table.isA('sap.m.Table');
|
|
52
|
+
function shown(ctrl) {
|
|
53
|
+
var d = ctrl && ctrl.getDomRef ? ctrl.getDomRef() : null;
|
|
54
|
+
return d ? String(d.innerText || d.textContent || '').replace(/\s+/g, ' ').trim() : '';
|
|
55
|
+
}
|
|
56
|
+
function headerText(col) {
|
|
57
|
+
var h = isMTable ? col.getHeader() : (col.getLabel ? col.getLabel() : null);
|
|
58
|
+
if (h && h.getText) { var t = h.getText(); if (t) return String(t); }
|
|
59
|
+
if (h && h.getLabel) { var l = h.getLabel(); if (l && l.getText) return String(l.getText()); }
|
|
60
|
+
return shown(h);
|
|
61
|
+
}
|
|
62
|
+
var allCols = table.getColumns();
|
|
63
|
+
var visibleIdx = [];
|
|
64
|
+
for (var i = 0; i < allCols.length; i++) if (allCols[i].getVisible()) visibleIdx.push(i);
|
|
65
|
+
var columns = visibleIdx.map(function (i) { return headerText(allCols[i]); });
|
|
66
|
+
var rows = [];
|
|
67
|
+
var items = isMTable ? table.getItems() : table.getRows();
|
|
68
|
+
for (var r = 0; r < items.length; r++) {
|
|
69
|
+
var item = items[r];
|
|
70
|
+
var ctx = item.getBindingContext();
|
|
71
|
+
if (!ctx) continue;
|
|
72
|
+
var cells = item.getCells();
|
|
73
|
+
var texts;
|
|
74
|
+
if (isMTable) {
|
|
75
|
+
texts = visibleIdx.map(function (i) { return shown(cells[i]); });
|
|
76
|
+
} else {
|
|
77
|
+
texts = cells.map(function (c) { return shown(c); });
|
|
78
|
+
}
|
|
79
|
+
rows.push({ path: String(ctx.getPath()), cells: texts });
|
|
80
|
+
}
|
|
81
|
+
return { columns: columns, rows: rows };
|
|
82
|
+
}`;
|
|
83
|
+
/**
|
|
84
|
+
* CAP phase 1 (F4): a 3xx is a redirect, not an answer — only the final response
|
|
85
|
+
* of a $batch carries a body to judge (SAP's CAP sample posts to a relative URL
|
|
86
|
+
* and gets a 308; an approuter can redirect too).
|
|
87
|
+
*/
|
|
88
|
+
export function isBatchCapturable(status) {
|
|
89
|
+
return status < 300 || status >= 400;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The browser reports a request re-sent after a redirect WITHOUT its
|
|
93
|
+
* content-type header although the body is there; take it from the original
|
|
94
|
+
* request in the redirect chain.
|
|
95
|
+
*/
|
|
96
|
+
export function effectiveRequestContentType(own, chain) {
|
|
97
|
+
return own || chain.find((c) => c) || '';
|
|
98
|
+
}
|
|
99
|
+
/** In-page: expand every collapsed sap.f.DynamicPage header; returns how many. */
|
|
100
|
+
const EXPAND_HEADERS_SRC = `(() => {
|
|
101
|
+
const reg = window.sap?.ui?.core?.Element?.registry;
|
|
102
|
+
let n = 0;
|
|
103
|
+
if (reg?.filter) {
|
|
104
|
+
for (const dp of reg.filter((e) => e.isA && e.isA('sap.f.DynamicPage'))) {
|
|
105
|
+
if (dp.getHeaderExpanded && dp.getHeaderExpanded() === false) { dp.setHeaderExpanded(true); n++; }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return n;
|
|
109
|
+
})()`;
|
|
110
|
+
export async function openApp(executablePath, url, timeoutMs, navTimeoutMs = timeoutMs,
|
|
111
|
+
/** L1-8b: a visible, slowed-down browser for recording a run on camera. */
|
|
112
|
+
launch = {}) {
|
|
113
|
+
// Loaded on use (fix round 3, item 11e) — not on every CLI start.
|
|
114
|
+
const { default: puppeteer } = await import('puppeteer-core');
|
|
115
|
+
const browser = await puppeteer.launch({
|
|
116
|
+
executablePath,
|
|
117
|
+
headless: launch.headless ?? true,
|
|
118
|
+
slowMo: launch.slowMo ?? 0,
|
|
119
|
+
});
|
|
29
120
|
const failed = [];
|
|
121
|
+
const observed = [];
|
|
30
122
|
const consoleErrs = [];
|
|
123
|
+
const pageErrs = [];
|
|
124
|
+
// CAP phase 1: requests still waiting — a login challenge holds them with no response event.
|
|
125
|
+
const inFlight = new Map();
|
|
126
|
+
// L1-5a: bodies of `$batch` exchanges only — read asynchronously, awaited by batchExchanges().
|
|
127
|
+
const batches = [];
|
|
31
128
|
try {
|
|
32
129
|
const page = await browser.newPage();
|
|
130
|
+
// A desktop-sized viewport (L1-2b; planned for L1-3). At puppeteer's default
|
|
131
|
+
// 800×600 a responsive table pops lower-importance columns into a sub-row, so
|
|
132
|
+
// their headers are not rendered and `columns` failed a correct app.
|
|
133
|
+
await page.setViewport({ width: 1600, height: 1000 });
|
|
134
|
+
// CAP path: the development login (mocked basic auth) for the proven local server.
|
|
135
|
+
if (launch.credentials)
|
|
136
|
+
await page.authenticate(launch.credentials);
|
|
33
137
|
// Collect BEFORE navigating so the initial document + its OData calls are seen.
|
|
138
|
+
page.on('request', (req) => {
|
|
139
|
+
inFlight.set(req, req.url());
|
|
140
|
+
});
|
|
141
|
+
page.on('requestfinished', (req) => inFlight.delete(req));
|
|
142
|
+
page.on('requestfailed', (req) => inFlight.delete(req));
|
|
34
143
|
page.on('response', (resp) => {
|
|
144
|
+
inFlight.delete(resp.request());
|
|
145
|
+
// L1-5b: fiori_draft_smoke's own in-page API calls (X-CSP-Smoke) are
|
|
146
|
+
// judged by the step that made them, not as the app's traffic.
|
|
147
|
+
if (resp.request().headers()['x-csp-smoke'] === '1')
|
|
148
|
+
return;
|
|
35
149
|
const status = resp.status();
|
|
150
|
+
observed.push({ url: resp.url(), status });
|
|
36
151
|
if (status >= 400)
|
|
37
152
|
failed.push({ url: resp.url(), status });
|
|
153
|
+
if (BATCH_URL.test(resp.url()) && isBatchCapturable(status)) {
|
|
154
|
+
const req = resp.request();
|
|
155
|
+
batches.push((async () => {
|
|
156
|
+
try {
|
|
157
|
+
const requestBody = req.postData() ?? (req.hasPostData() ? await req.fetchPostData() : '') ?? '';
|
|
158
|
+
return {
|
|
159
|
+
requestContentType: effectiveRequestContentType(req.headers()['content-type'] ?? '', req.redirectChain().map((r) => r.headers()['content-type'] ?? '')),
|
|
160
|
+
requestBody,
|
|
161
|
+
status,
|
|
162
|
+
responseContentType: resp.headers()['content-type'] ?? '',
|
|
163
|
+
responseBody: await resp.text(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined; // body gone (page navigated) — nothing to judge for this one
|
|
168
|
+
}
|
|
169
|
+
})());
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
page.on('pageerror', (err) => {
|
|
173
|
+
pageErrs.push(err instanceof Error ? err.message : String(err));
|
|
38
174
|
});
|
|
39
175
|
page.on('console', (msg) => {
|
|
40
176
|
if (msg.type() === 'error')
|
|
41
177
|
consoleErrs.push(msg.text());
|
|
42
178
|
});
|
|
43
|
-
|
|
179
|
+
try {
|
|
180
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: navTimeoutMs });
|
|
181
|
+
}
|
|
182
|
+
catch (navErr) {
|
|
183
|
+
// L1-7: a page that did not settle is a boot FAILURE — keep a picture of
|
|
184
|
+
// what it showed before the browser closes (best-effort).
|
|
185
|
+
if (navErr instanceof Error && (navErr.name === 'TimeoutError' || /timeout/i.test(navErr.message))) {
|
|
186
|
+
try {
|
|
187
|
+
const shot = path.join(os.tmpdir(), `cspeach-nav-timeout-${Date.now()}.png`);
|
|
188
|
+
await page.screenshot({ path: shot });
|
|
189
|
+
navErr.screenshotPath = shot;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// no picture — the failure still stands
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
throw navErr;
|
|
196
|
+
}
|
|
44
197
|
return {
|
|
45
198
|
currentUrl: () => page.url(),
|
|
46
199
|
waitForSelector: (selector) => page.waitForSelector(selector, { timeout: timeoutMs }),
|
|
47
200
|
queryTexts: (selector) => page.$$eval(selector, (els) => els.map((el) => (el.textContent ?? '').trim())),
|
|
48
201
|
failedRequests: () => [...failed],
|
|
49
202
|
consoleErrors: () => [...consoleErrs],
|
|
203
|
+
observedRequests: () => [...observed],
|
|
204
|
+
unansweredRequests: () => [...inFlight.values()],
|
|
205
|
+
pageErrors: () => [...pageErrs],
|
|
206
|
+
batchExchanges: async () => (await Promise.all([...batches])).filter((b) => b !== undefined),
|
|
207
|
+
evalInPage: (functionSource, arg) => page.evaluate(`(${functionSource})(${JSON.stringify(arg)})`),
|
|
208
|
+
cellTextsByColumn: async (entitySet) => {
|
|
209
|
+
const r = await page.evaluate(`(${TABLE_SNAPSHOT_SRC})(${JSON.stringify(entitySet)})`);
|
|
210
|
+
return (r ?? undefined);
|
|
211
|
+
},
|
|
50
212
|
// Track 3 exercises (live leg only — never unit-tested; the suite injects
|
|
51
213
|
// fakes). `selectorHint` is a REAL DOM selector derived from the control id
|
|
52
214
|
// (`[id$="--<id>"]`), so a plain click drives the press handler.
|
|
53
215
|
press: async (selectorHint) => {
|
|
216
|
+
// UI5 1.84 collapses the list report's header after an on-open load,
|
|
217
|
+
// hiding the filter bar (Go). Expand it first, as a user would.
|
|
218
|
+
const expanded = await page.evaluate(EXPAND_HEADERS_SRC).catch(() => 0);
|
|
219
|
+
if (expanded)
|
|
220
|
+
await page.waitForNetworkIdle({ idleTime: 300, timeout: timeoutMs }).catch(() => { });
|
|
54
221
|
await page.click(selectorHint, { delay: 0 });
|
|
55
222
|
await page.waitForNetworkIdle({ idleTime: 200, timeout: timeoutMs }).catch(() => { });
|
|
56
223
|
},
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1-8b (review F-17) — the evidence pack. Every real smoke run writes
|
|
3
|
+
* `evidence.json` beside its screenshots: what was checked, what the service
|
|
4
|
+
* audit said, the inner $batch log and how long each part took. LOCAL files
|
|
5
|
+
* only (under ~/.cspeach/smoke/… by default) — never sent anywhere.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
/** The viewport every smoke renders at (driver.ts sets it). */
|
|
10
|
+
export const SMOKE_VIEWPORT = { width: 1600, height: 1000 };
|
|
11
|
+
/** A compact, stable summary of the audit — enough to see what was judged. */
|
|
12
|
+
export function auditSummary(audit) {
|
|
13
|
+
if (!audit)
|
|
14
|
+
return undefined;
|
|
15
|
+
return {
|
|
16
|
+
servicePath: audit.servicePath,
|
|
17
|
+
entitySet: audit.entitySet,
|
|
18
|
+
entityType: audit.entityType,
|
|
19
|
+
properties: audit.properties.length,
|
|
20
|
+
lineItemColumns: audit.lineItem.length,
|
|
21
|
+
flags: audit.flags,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Write `evidence.json` into `dir`; returns its path, or undefined if the write failed. */
|
|
25
|
+
export function writeEvidence(dir, data) {
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
const file = path.join(dir, 'evidence.json');
|
|
29
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
30
|
+
return file;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined; // evidence is best-effort — it never fails the smoke
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { normaliseHeader } from './assertions.js';
|
|
2
|
+
/** At most this many rows are compared (plan: "the first 20 rows"). */
|
|
3
|
+
export const MAX_CODE_ROWS = 20;
|
|
4
|
+
// ─────────────────────────────── labels ───────────────────────────────
|
|
5
|
+
export function checkLabels(snapshot, expected, audit) {
|
|
6
|
+
if (!snapshot) {
|
|
7
|
+
return {
|
|
8
|
+
name: 'labels',
|
|
9
|
+
ok: false,
|
|
10
|
+
detail: `could not read the list table for /${audit.entitySet} — no labels were checked`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const rendered = new Set(snapshot.columns.map(normaliseHeader));
|
|
14
|
+
const unlabelled = expected.filter((c) => !c.label).map((c) => c.property);
|
|
15
|
+
const missing = expected.filter((c) => c.label && !rendered.has(normaliseHeader(c.label))).map((c) => c.label);
|
|
16
|
+
// A header equal to a technical name counts only where that name is NOT also
|
|
17
|
+
// the property's own label ("Inspector" labelled "Inspector" is fine).
|
|
18
|
+
// A header equal to an expected COLUMN label is that label (CAP: LineItem
|
|
19
|
+
// label "Author" on property `author` whose own label is "Name").
|
|
20
|
+
const columnLabels = new Set(expected.filter((c) => c.label).map((c) => normaliseHeader(c.label)));
|
|
21
|
+
const technical = snapshot.columns.filter((h) => !columnLabels.has(normaliseHeader(h)) &&
|
|
22
|
+
audit.properties.some((p) => normaliseHeader(p.name) === normaliseHeader(h) && normaliseHeader(p.label ?? '') !== normaliseHeader(p.name)));
|
|
23
|
+
// UI5 1.84 renders an unlabelled column with an EMPTY header (1.120 shows the
|
|
24
|
+
// technical name instead) — both are a missing label the user can see.
|
|
25
|
+
const empty = snapshot.columns.filter((h) => normaliseHeader(h) === '').length;
|
|
26
|
+
const ok = unlabelled.length === 0 && missing.length === 0 && technical.length === 0 && empty === 0;
|
|
27
|
+
const parts = [];
|
|
28
|
+
if (unlabelled.length)
|
|
29
|
+
parts.push(`no label for: ${unlabelled.join(', ')}`);
|
|
30
|
+
if (missing.length)
|
|
31
|
+
parts.push(`label not rendered: ${missing.join(', ')}`);
|
|
32
|
+
if (technical.length)
|
|
33
|
+
parts.push(`technical name shown as a header: ${technical.join(', ')}`);
|
|
34
|
+
if (empty)
|
|
35
|
+
parts.push(`${empty} column header(s) are empty`);
|
|
36
|
+
return {
|
|
37
|
+
name: 'labels',
|
|
38
|
+
ok,
|
|
39
|
+
detail: ok
|
|
40
|
+
? `all ${expected.length} column(s) rendered under their labels`
|
|
41
|
+
: `${parts.join('; ')} (rendered: ${snapshot.columns.join(', ') || 'none'})`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// ──────────────────────────── no-raw-codes ────────────────────────────
|
|
45
|
+
/**
|
|
46
|
+
* Does `cell` show `code` as a value of its own? The text is taken out first,
|
|
47
|
+
* then the rest is split into word tokens: TextFirst "Open (O)" leaves "(O)" →
|
|
48
|
+
* shows the code; "Open Order" does not (the O is inside a word).
|
|
49
|
+
*/
|
|
50
|
+
export function cellShowsCode(cell, code, text) {
|
|
51
|
+
const rest = text ? cell.replace(text, ' ') : cell;
|
|
52
|
+
if (/^[\p{L}\p{N}]+$/u.test(code)) {
|
|
53
|
+
return rest.split(/[^\p{L}\p{N}]+/u).includes(code);
|
|
54
|
+
}
|
|
55
|
+
return rest.includes(code);
|
|
56
|
+
}
|
|
57
|
+
/** Coded properties that the list shows, with the column they sit in. */
|
|
58
|
+
function codedColumns(audit, expected) {
|
|
59
|
+
const byName = new Map(audit.properties.map((p) => [p.name, p]));
|
|
60
|
+
return expected
|
|
61
|
+
.map((c) => ({ col: c, prop: byName.get(c.property) }))
|
|
62
|
+
.filter((x) => x.prop?.coded);
|
|
63
|
+
}
|
|
64
|
+
export function judgeRawCodes(audit, expected, snapshot, truths) {
|
|
65
|
+
const coded = codedColumns(audit, expected);
|
|
66
|
+
if (coded.length === 0) {
|
|
67
|
+
return { name: 'no-raw-codes', ok: true, detail: 'no coded column in the list — nothing to check' };
|
|
68
|
+
}
|
|
69
|
+
if (!snapshot) {
|
|
70
|
+
return {
|
|
71
|
+
name: 'no-raw-codes',
|
|
72
|
+
ok: false,
|
|
73
|
+
detail: `could not read the list table for /${audit.entitySet}; coded column(s) unchecked: ${coded.map((c) => c.col.property).join(', ')}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const rows = snapshot.rows.slice(0, MAX_CODE_ROWS);
|
|
77
|
+
const headers = snapshot.columns.map(normaliseHeader);
|
|
78
|
+
const failures = [];
|
|
79
|
+
let checked = 0;
|
|
80
|
+
for (const { col, prop } of coded) {
|
|
81
|
+
if (!prop.text) {
|
|
82
|
+
failures.push(`${col.property} is coded but has no text (Common.Text) — its cells can only show raw codes`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const idx = col.label ? headers.indexOf(normaliseHeader(col.label)) : -1;
|
|
86
|
+
if (idx < 0) {
|
|
87
|
+
failures.push(`${col.property}: its column "${col.label ?? col.property}" is not rendered`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const perRow = truths?.get(col.property);
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
const truth = perRow?.get(row.path);
|
|
93
|
+
if (!truth) {
|
|
94
|
+
failures.push(`${col.property} ${row.path}: the backend value could not be read`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (truth.code === null || truth.code === '')
|
|
98
|
+
continue; // nothing to display
|
|
99
|
+
const cell = (row.cells[idx] ?? '').trim();
|
|
100
|
+
checked++;
|
|
101
|
+
if (!truth.text) {
|
|
102
|
+
failures.push(`${col.property} ${row.path}: code "${truth.code}" has no text`);
|
|
103
|
+
}
|
|
104
|
+
else if (!cell.includes(truth.text)) {
|
|
105
|
+
failures.push(`${col.property} ${row.path}: cell "${cell}" does not show the text "${truth.text}"`);
|
|
106
|
+
}
|
|
107
|
+
else if (cellShowsCode(cell, truth.code, truth.text)) {
|
|
108
|
+
failures.push(`${col.property} ${row.path}: cell "${cell}" shows the raw code "${truth.code}"`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const ok = failures.length === 0;
|
|
113
|
+
return {
|
|
114
|
+
name: 'no-raw-codes',
|
|
115
|
+
ok,
|
|
116
|
+
detail: ok
|
|
117
|
+
? `${coded.length} coded column(s), ${rows.length} row(s): text shown, no raw code (${checked} cell(s) compared)`
|
|
118
|
+
: failures.slice(0, 6).join('; ') + (failures.length > 6 ? `; … ${failures.length - 6} more` : ''),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// ───────────────────────── reading the backend ─────────────────────────
|
|
122
|
+
/** `/…/0001/$metadata` → `/…/0001/`. */
|
|
123
|
+
export function serviceRootOf(servicePath) {
|
|
124
|
+
const root = servicePath.replace(/\$metadata(\?.*)?$/, '');
|
|
125
|
+
return root.endsWith('/') ? root : `${root}/`;
|
|
126
|
+
}
|
|
127
|
+
/** `$select`/`$expand` for the coded properties and their text paths (one nav level). */
|
|
128
|
+
export function rowTruthQuery(items, language) {
|
|
129
|
+
const select = [];
|
|
130
|
+
const expand = new Map();
|
|
131
|
+
for (const { property, textPath } of items) {
|
|
132
|
+
if (!select.includes(property))
|
|
133
|
+
select.push(property);
|
|
134
|
+
const slash = textPath.indexOf('/');
|
|
135
|
+
if (slash < 0) {
|
|
136
|
+
if (!select.includes(textPath))
|
|
137
|
+
select.push(textPath);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const nav = textPath.slice(0, slash);
|
|
141
|
+
const leaf = textPath.slice(slash + 1);
|
|
142
|
+
expand.set(nav, [...(expand.get(nav) ?? []), leaf]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const parts = [`$select=${select.join(',')}`];
|
|
146
|
+
if (expand.size > 0) {
|
|
147
|
+
parts.push(`$expand=${[...expand].map(([nav, leaves]) => `${nav}($select=${leaves.join(',')})`).join(',')}`);
|
|
148
|
+
}
|
|
149
|
+
parts.push(`sap-language=${encodeURIComponent(language)}`);
|
|
150
|
+
return parts.join('&');
|
|
151
|
+
}
|
|
152
|
+
function valueAt(obj, path) {
|
|
153
|
+
let cur = obj;
|
|
154
|
+
for (const seg of path.split('/')) {
|
|
155
|
+
if (cur === null || typeof cur !== 'object')
|
|
156
|
+
return null;
|
|
157
|
+
cur = cur[seg];
|
|
158
|
+
}
|
|
159
|
+
return cur === null || cur === undefined ? null : String(cur);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* In-page script (a function-expression SOURCE, not a closure — puppeteer
|
|
163
|
+
* serialises it, and a transpiled function can carry helpers the page lacks).
|
|
164
|
+
* Fetches each row path with the app's own session and returns the JSON bodies.
|
|
165
|
+
*/
|
|
166
|
+
const FETCH_ROWS_SRC = `async function (a) {
|
|
167
|
+
var out = {};
|
|
168
|
+
for (var i = 0; i < a.paths.length; i++) {
|
|
169
|
+
var p = a.paths[i];
|
|
170
|
+
try {
|
|
171
|
+
var r = await fetch(a.root + p.replace(/^\\//, '') + '?' + a.query, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
|
|
172
|
+
out[p] = r.ok ? await r.json() : null;
|
|
173
|
+
} catch (e) { out[p] = null; }
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}`;
|
|
177
|
+
/**
|
|
178
|
+
* For every coded-with-text column the list shows, read the first
|
|
179
|
+
* MAX_CODE_ROWS rows' code + text from the backend, FROM INSIDE THE PAGE.
|
|
180
|
+
* Returns undefined when the page cannot evaluate (the judge then fails).
|
|
181
|
+
*/
|
|
182
|
+
export async function readRowTruths(page, audit, expected, snapshot) {
|
|
183
|
+
const items = codedColumns(audit, expected)
|
|
184
|
+
.filter((x) => x.prop.text)
|
|
185
|
+
.map((x) => ({ property: x.col.property, textPath: x.prop.text.path }));
|
|
186
|
+
if (items.length === 0)
|
|
187
|
+
return new Map();
|
|
188
|
+
if (!page.evalInPage)
|
|
189
|
+
return undefined;
|
|
190
|
+
const paths = snapshot.rows.slice(0, MAX_CODE_ROWS).map((r) => r.path).filter((p) => p.startsWith('/'));
|
|
191
|
+
const bodies = await page.evalInPage(FETCH_ROWS_SRC, {
|
|
192
|
+
root: serviceRootOf(audit.servicePath),
|
|
193
|
+
paths,
|
|
194
|
+
query: rowTruthQuery(items, audit.language || 'EN'),
|
|
195
|
+
});
|
|
196
|
+
const out = new Map();
|
|
197
|
+
for (const { property, textPath } of items) {
|
|
198
|
+
const perRow = new Map();
|
|
199
|
+
for (const p of paths) {
|
|
200
|
+
const body = bodies?.[p];
|
|
201
|
+
if (body && typeof body === 'object')
|
|
202
|
+
perRow.set(p, { code: valueAt(body, property), text: valueAt(body, textPath) });
|
|
203
|
+
}
|
|
204
|
+
out.set(property, perRow);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1-7 (review F-14) — classify a failed first navigation.
|
|
3
|
+
*
|
|
4
|
+
* Before, run-smoke swallowed every openApp error as "unreachable" and
|
|
5
|
+
* skipped: a timeout on a cold UI5 CDN load, a refused port and a certificate
|
|
6
|
+
* error all looked the same, and a skip is never a failure — the July flake.
|
|
7
|
+
* Only `refused` is worth retrying (the preview is still starting); a timeout
|
|
8
|
+
* means the app did not settle and is a real `boot` failure.
|
|
9
|
+
*/
|
|
10
|
+
/** First navigation: long enough for a cold UI5 CDN load. */
|
|
11
|
+
export const NAV_TIMEOUT_MS = 90_000;
|
|
12
|
+
/** Attempts for a refused connection, and the pause between them. */
|
|
13
|
+
export const REFUSED_ATTEMPTS = 3;
|
|
14
|
+
export const REFUSED_BACKOFF_MS = 2_000;
|
|
15
|
+
export function classifyNavError(err) {
|
|
16
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17
|
+
const name = err instanceof Error ? err.name : '';
|
|
18
|
+
if (name === 'TimeoutError' || /navigation timeout|timeout of \d+ ?ms exceeded/i.test(message)) {
|
|
19
|
+
return { kind: 'timeout', message };
|
|
20
|
+
}
|
|
21
|
+
if (/ERR_CONNECTION_REFUSED|ECONNREFUSED/i.test(message))
|
|
22
|
+
return { kind: 'refused', message };
|
|
23
|
+
if (/ERR_CERT_|ERR_SSL_|CERT_|self[- ]signed|unable to verify/i.test(message))
|
|
24
|
+
return { kind: 'cert', message };
|
|
25
|
+
return { kind: 'other', message };
|
|
26
|
+
}
|