Repository navigation
Expand file tree
/
Copy pathlib-e2e.mjs
More file actions
428 lines (408 loc) · 23.8 KB
/
Copy pathlib-e2e.mjs
File metadata and controls
428 lines (408 loc) · 23.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
/*
* lib-e2e — shared assertion helpers for the per-port e2e interactions.
*
* The interactions live as one module per port under meta/interactions/
* (loaded by scripts/e2e-smoke.mjs); these helpers are the shared assertions
* several of them use. Moved verbatim out of e2e-smoke.mjs when the
* INTERACTIONS map was externalized (2026-08-10).
*/
// poll until a selector reaches at least n matches
export async function waitForCount(page, selector, n, msg) {
const deadline = Date.now() + 10000;
for (;;) {
if ((await page.locator(selector).count()) >= n) return;
if (Date.now() > deadline) throw new Error(msg);
await new Promise((r) => setTimeout(r, 250));
}
}
// the supplier record seeded at the model root must reach the form Inputs
export async function formFieldValues(page) {
await waitForUi5(page, () => {
const v = ui5All().filter((c) => c.getMetadata().getName() === 'sap.m.Input').map((c) => c.getValue());
return ['Red Point Stores', 'Main St', '31415'].every((x) => v.includes(x));
}, 'the root-seeded supplier record never reached the form Inputs');
}
// one key press on the slider must move the bound width to 99 % — the shared
// assertion of the "controller sets a width" class (see AGENTS §10 on why the
// handle is focused through the DOM instead of clicked)
export async function sliderDrivenWidth(page, control) {
const handle = page.locator('.sapMSliderHandle').first();
if (!(await handle.count())) throw new Error('the width slider rendered no handle');
await page.evaluate(() => document.querySelector('.sapMSliderHandle').focus());
await page.keyboard.press('ArrowLeft');
await waitForUi5(page, (name) => ui5All().some((c) => c.getMetadata().getName() === name && c.getWidth() === '99%'),
`no ${control} width followed the slider to 99%`, control);
}
export async function valueStateRows(page, expect, control) {
await expect(page.locator('body'), 'the row labels').toContainText(`${control} with valueState Error`);
for (const state of ['Information', 'Success', 'Warning', 'Error']) {
const n = await page.locator(`.sapMInputBaseContentWrapper${state}`).count();
if (n !== 1) throw new Error(`expected exactly one ${state} row to render that state, got ${n}`);
}
// the bound valueStateText reaches the DOM (InputBaseRenderer always writes
// it into the invisible -sr node, so this holds without opening the popup)
await expect(page.locator('body'), 'the bound valueStateText of the Warning row')
.toContainText('Warning message. This is an extra long text');
}
// The uxap ObjectPageHeader markers — the changes marker, the lock marker and
// the title-selector arrow — are INTERNAL sap.m.Buttons: no text to match on,
// a generated id ending in a known suffix (`-changes`, `-lock`, `-titleArrow`,
// each also in a `-cont` variant used when the title sits in the header
// content), and icon-only, so the harness' unthemed icon font leaves them a
// zero-size box that no actionability check passes. Dispatch the click the way
// the icon lesson prescribes rather than giving the control up.
export async function pressHeaderMarker(page, suffix) {
const marks = page.locator(`[id$="-${suffix}"], [id$="-${suffix}-cont"]`);
const n = await marks.count();
if (!n) throw new Error(`the ObjectPageHeader rendered no "${suffix}" marker button`);
for (let i = 0; i < n; i++) {
// measured 2026-08-21: these markers DO get a layout box (123x22) even
// unthemed, so a real click is what fires their press — a dispatched
// 'click' alone reaches the DOM node without ever becoming a UI5 press.
if (await marks.nth(i).isVisible()) return marks.nth(i).click();
}
return dispatchMouse(marks.first());
}
// A control that measures zero in the unthemed harness cannot be clicked, and
// a lone dispatched 'click' is not enough for everything: sap.ui.table's
// pointer extension acts on the mousedown/mouseup pair, so its tree expand
// icon ignores a bare click event and only the whole sequence toggles the node
// (measured 2026-08-21 on app 364 — click alone left the tree collapsed).
export async function dispatchMouse(locator) {
await locator.dispatchEvent('mousedown');
await locator.dispatchEvent('mouseup');
await locator.dispatchEvent('click');
}
// The sap.ui.table extension toolbar is an OverflowToolbar, and at the smoke's
// viewport it folds EVERY one of its controls into the "Additional Options"
// popover — so a direct locator for a toolbar control fails with "not visible"
// or a 30s timeout that reads like a broken port. This puts the wanted control
// back on screen: it returns at once if the control is already visible (the
// port may render wide enough not to overflow at all), and otherwise opens the
// overflows in turn until the control appears.
//
// Trying them IN TURN rather than taking the first is the point: a page can
// carry several OverflowToolbars — app 357 has one on the table and a second
// in the footer, so "the first Additional Options button" opens the wrong one
// and the control still never shows. A round-trip re-renders the toolbar and
// closes the popover, so call this again before each toolbar interaction.
//
// A popup that is still CLOSING keeps its content visible for a moment, so the
// "already visible" check first waits for every popup to settle. demo_003 red
// on three nightlies (2026-10-04..06): the Create press had closed the
// overflow, the legend button inside it still read visible while the popover
// was CLOSING, this returned without opening anything, and the next click
// found a display:none button ("Element is not visible"). Only reproducible
// after other apps had warmed the browser - a slower run saw it CLOSED.
export async function revealInOverflow(page, locator) {
await page.waitForFunction(() => !Object.values(sap.ui.require('sap/ui/core/Element').registry.all())
.some((c) => c.oPopup && typeof c.oPopup.getOpenState === 'function'
&& /^(CLOSING|OPENING)$/.test(c.oPopup.getOpenState())), undefined, { timeout: 5000 }).catch(() => {});
const shown = async () => (await locator.count()) && (await locator.first().isVisible());
if (await shown()) return;
const more = page.getByRole('button', { name: 'Additional Options' });
const n = await more.count();
for (let i = 0; i < n; i++) {
await more.nth(i).click();
await page.waitForTimeout(600);
if (await shown()) return;
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
throw new Error(`the control never appeared — tried ${n} overflow popover(s)`);
}
/* A popover or dialog located by what it SAYS - its title, else its text -
* among the ones that are OPEN, never as "the first .sapMPopover".
*
* The first one in the DOM is often not yours. A popup UI5 has closed stays
* in the static area, hidden, ahead of every popup opened after it: once
* revealInOverflow( ) has opened a toolbar's overflow, `.sapMPopover` first
* names that hidden associative popover, and a wait on it times out while the
* real one is open (the overview's generation-notes leg, red behind #259's
* subheader overflow, 2026-10-09). Visibility is no cure either: a popover
* whose content overflows its box measures empty headless (app 238) and
* Playwright calls it hidden while it is open and showing its text.
*
* So the candidates come from UI5, not from the DOM: every sap.m.Popover and
* sap.m.Dialog (which covers ResponsivePopover, MessageBox, SelectDialog,
* ViewSettingsDialog, ActionSheet, MessagePopover and the pickers - each
* renders through one of the two) that isOpen( ) with its node in the
* document. Among those a TITLE match wins over a text match, and the most
* recently created wins a tie. `want` is a substring or a RegExp. Resolves to
* a locator on that popup's own node; fails naming the popups that WERE open.
*/
export async function waitForPopup(page, want, { timeout = 10000 } = {}) {
const arg = want instanceof RegExp ? { source: want.source, flags: want.flags } : String(want);
const expr = `(() => { ${OPEN_POPUPS_SRC}
const hit = (${choosePopup.toString()})(openPopups(), ${JSON.stringify(arg)});
return hit ? hit.id : null; })()`;
const handle = await page.waitForFunction(expr, undefined, { timeout }).catch(async (e) => {
if (!/Timeout .* exceeded/.test(String(e && e.message))) throw e;
const open = await page.evaluate(`(() => { ${OPEN_POPUPS_SRC} return openPopups(); })()`).catch(() => []);
const seen = open.map((p) => `${p.type} "${p.title || p.text.slice(0, 40)}"`).join(', ') || 'none';
throw new Error(`no open popover or dialog shows ${want instanceof RegExp ? want : `"${want}"`} - open: ${seen}`);
});
const id = await handle.jsonValue();
return page.locator(`[id="${id}"]`);
}
// page side of waitForPopup( ): every OPEN popover/dialog as plain data
const OPEN_POPUPS_SRC = `const openPopups = () => Object.values(sap.ui.require('sap/ui/core/Element').registry.all())
.filter((c) => !c.bIsDestroyed && c.isA && c.isA(['sap.m.Popover', 'sap.m.Dialog']) && c.isOpen && c.isOpen())
.map((c) => {
const dom = c.getDomRef();
if (!dom || !document.body.contains(dom)) return null;
// a custom header (ViewSettingsDialog, ResponsivePopover) carries its
// title as a sap.m.Title inside the header bar
const head = dom.querySelector('header');
const named = head && head.querySelector('.sapMTitle, .sapMDialogTitle, [role="heading"]');
const headText = named ? named.textContent : head ? head.innerText || head.textContent : '';
return { id: dom.id, type: c.getMetadata().getName(),
title: ((c.getTitle && c.getTitle()) || headText || '').replace(/\\s+/g, ' ').trim(),
text: (dom.innerText || dom.textContent || '').replace(/\\s+/g, ' ').trim() };
})
.filter(Boolean);`;
/* The choice itself, pure so it is testable without a browser (it is also
* stringified into the page, so it stays self-contained): `popups` are open
* ones in creation order, `want` a substring or { source, flags }. */
export function choosePopup(popups, want) {
const re = typeof want === 'string' ? null : new RegExp(want.source, want.flags);
const match = (t) => !!t && (re ? re.test(t) : t.includes(want));
const last = (list) => (list.length ? list[list.length - 1] : null);
return last(popups.filter((p) => match(p.title))) || last(popups.filter((p) => match(p.text)));
}
// Type into a field whose liveChange ROUND-TRIPS per keystroke. Such a wire is
// lossy, not queued: a round-trip in flight drops the events behind it, which
// is what the linter's own `live-event-roundtrip` advisory says about these
// ports. A fixed inter-key delay only makes the loss less likely, never
// impossible — measured 2026-08-21 on app 407, `pressSequentially` with a
// 300ms delay swallowed the "a" and the backend filtered on "Sles", which then
// matched nothing and produced an assertion failure that looked like a broken
// filter. So each character waits for the BOUND VALUE to catch up before the
// next one is pressed, which is deterministic instead of merely likely.
export async function typeLive(page, locator, text, idSuffix) {
const read = () => page.evaluate(`(() => { ${UI5_ALL_SRC}
const f = ui5All().find((c) => c.getId().endsWith(${JSON.stringify(idSuffix)}));
return f ? f.getValue() : null; })()`);
await locator.click();
for (let i = 0; i < text.length; i++) {
const want = text.slice(0, i + 1);
let settled = false;
for (let attempt = 0; attempt < 4 && !settled; attempt++) {
await locator.press(text[i]);
await page.waitForTimeout(900); // land AND settle: a late echo arrives here, not later
settled = (await read()) === want;
if (!settled) await locator.fill(text.slice(0, i)); // put the prefix back and retry
}
if (!settled) {
throw new Error(`the live field never settled on "${want}" — the round-trip keeps overwriting the typed value`);
}
}
}
// A uxap ObjectPageHeaderActionButton renders ICON-ONLY in the header: its
// text is not painted and the button's accessible name comes from the TOOLTIP
// instead, so getByRole('button', { name: <the button's text> }) matches
// nothing at all (measured 2026-08-21 on app 408, where the "toggle title"
// button answers to "synchronize"). Resolve it through the control registry,
// which is the only place its text still exists.
export async function pressHeaderAction(page, text) {
const id = await page.evaluate(`(() => { ${UI5_ALL_SRC}
const b = ui5All().find((c) => /ActionButton$/.test(c.getMetadata().getName())
&& c.getText && c.getText() === ${JSON.stringify(text)});
return b ? b.getId() : null; })()`);
if (!id) throw new Error(`the header has no action button with text "${text}"`);
return page.locator(`[id="${id}"]`).first().click();
}
// A Breadcrumbs control folds its links into a Select once they no longer fit,
// and at the smoke's viewport the uxap header samples do exactly that — so a
// plain getByRole('link') dies in a locator timeout that reads like a broken
// port (the same shape as the OverflowToolbar lesson). Take whichever form is
// on the page.
// Located by TEXT, never by role+name: a Breadcrumbs link renders as
// `<a aria-labelledby="<itself> <the current-location text>">`, so its
// ACCESSIBLE NAME is its own text plus the trailing location — and
// getByRole('link', { name, exact: true }) therefore matches nothing at all
// (measured 2026-08-21 on app 416: byRole 0 hits, byText 1).
export async function pressBreadcrumb(page, text) {
const link = page.locator('.sapMBreadcrumbs').getByText(text, { exact: true }).first();
if ((await link.count()) && (await link.isVisible())) return link.click();
const select = page.locator('.sapMBreadcrumbs .sapMSlt').first();
if (!(await select.count())) throw new Error(`no breadcrumb link "${text}", and no collapsed breadcrumbs select either`);
await select.click();
return page.locator('.sapMSltPicker li', { hasText: text }).first().click();
}
// in-page helpers for property assertions: `ui5All()` is only ever passed
// INTO page.evaluate (it is stringified there, so it must stay self-contained)
export function ui5All() { throw new Error('ui5All() runs in the page only'); }
export const UI5_ALL_SRC = 'const ui5All = () => Object.values(sap.ui.require("sap/ui/core/Element").registry.all());';
// A predicate that THROWS is not a predicate that is false. waitForFunction
// rejects either way, and swallowing the reason turns a broken assertion into a
// confident lie about the port: app 351's Remove wire reported itself as
// "never shrank the bound contentAreas aggregation" for three runs while a
// direct dump after the same press showed it had shrunk — the predicate was
// calling getDomRef() on a control the re-render had already destroyed, which
// throws. So a rejection that is not a timeout keeps its own message.
export async function waitForUi5(page, fn, msg, arg) {
const expr = `(() => { ${UI5_ALL_SRC} return (${fn.toString()})(${JSON.stringify(arg ?? null)}); })()`;
await page.waitForFunction(expr, undefined, { timeout: 15000 }).catch((e) => {
const why = String(e && e.message);
if (/Timeout .* exceeded/.test(why)) throw new Error(msg);
throw new Error(`${msg} — but the check itself failed: ${why.split('\n')[0].slice(0, 140)}`);
});
}
/* The frontend state of the running z2ui5.Component, as a page-side EXPRESSION
* (splice it into a page.evaluate / waitForFunction string; null when no
* component has booted yet).
*
* Where it lives has moved twice. Until 2026-09-22 it was mirrored on
* `window.z2ui5`; abap2UI5#2777 removed that global and left one module-level
* `state` on z2ui5/core/AppState; abap2UI5#2780 (2026-09-23) moved it onto a
* per-component context, `component.ctx.state`, so two components on a page
* no longer share it - and `AppState.state` is simply undefined now. A reader
* that still asked AppState got nothing back and did not fail: every
* bookmark-restore leg reported "no draft id on the response", and
* waitForIdle took the "older frontend" exit and stopped waiting at all.
*
* The component is found through UI5's own registry (Component.registry,
* since 1.67); the AppState fallback keeps a pin between #2777 and #2780
* working. The harness boots one component per page. */
export const FRONTEND_STATE = `((() => {
const C = sap.ui.require('sap/ui/core/Component');
const own = C && C.registry ? C.registry.filter((c) => c.ctx && c.ctx.state)[0] : null;
if (own) return own.ctx.state;
const legacy = sap.ui.require('z2ui5/core/AppState');
return (legacy && legacy.state) || null;
})())`;
// the draft id of the last response - what a bookmark-restore URL
// (`?app_start=<class>#/z2ui5-xapp-state=<draft>`) is built from
export async function draftId(page) {
return page.evaluate(`(${FRONTEND_STATE} || {}).oResponse?.ID || null`);
}
/* The app is not answering a roundtrip of its own.
*
* THE FRONTEND DROPS AN EVENT FIRED WHILE ONE IS IN FLIGHT, and silently:
* View1.eB( ) returns early on the frontend state's `isBusy`, showing the busy
* indicator and nothing else. No error, no console line, no request. From here
* that looks exactly like a wire that was never attached - the press fires,
* the listener is there, and the backend simply never hears about it.
*
* It cost four ports to learn (575, 578, 579, 584, all flexible-column
* layouts). Their interactions press a row as soon as the master table has
* rendered, and rendering a FlexibleColumnLayout fires columnResize, which
* those samples wire to the backend - so the boot roundtrip they start was
* still open when the press landed, and the press went nowhere. The failure
* read as "pressing a row never opened the mid column", which is true and
* says nothing about why.
*
* So: wait for the app to be idle before driving it. Not `networkidle` -
* that is the browser's view and says nothing about a queue this frontend
* keeps itself - and not a sleep, which is the same race with a longer fuse.
* Two consecutive frames of not-busy, because `isBusy` dips between a
* response landing and the next event being queued.
*/
export async function waitForIdle(page, { quiet = 400, timeout = 30000 } = {}) {
/* SUSTAINED quiet, not a single not-busy reading. `isBusy` dips to false
between a response landing and the next event being queued, and a boot
that settles a layout fires several in a row - a one-shot check sails
through the gap and the press is dropped anyway (measured: isBusy was
still true one frame after such a check returned).
The quiet is measured from THIS call: the timestamp lives on `window`, and
left over from the previous call it made every later call return at its
first poll - an Enter whose round-trip had not started yet read as "quiet
since long ago", and demo_004 typed its security code into the field the
previous field's answer then reset (2026-10-07, "___" with valueState
Error, reproducible only after other apps had warmed the browser). */
await page.evaluate(() => { window.__a2ui5IdleSince = 0; });
const expr = `(() => {
const s = ${FRONTEND_STATE};
if (!s) return true; // no component booted: nothing to wait for
window.__a2ui5IdleSince = s.isBusy === true ? 0 : (window.__a2ui5IdleSince || Date.now());
return window.__a2ui5IdleSince > 0 && Date.now() - window.__a2ui5IdleSince >= ${quiet};
})()`;
await page.waitForFunction(expr, undefined, { timeout }).catch(() => {
throw new Error(`the app never went quiet for ${quiet}ms - it is still answering roundtrips of its own`);
});
}
/*
* The busy-overlay half of a per-keystroke wire.
*
* `check_queue_last` stops keystrokes being lost; `check_no_busy` stops the
* full-screen busy overlay being raised for the wire. Only the second one is
* visible to a user, and it is the one no assertion could see: the port still
* ends on the typed value either way, so a module that checks the VALUE is
* green with the overlay flashing over the field on every keystroke from the
* second character on (the first trip raises the indicator after the usual
* delay; a keystroke landing on a trip already in flight raises it with NO
* delay - see z2ui5_cl_ui5f_view1_js `if (!noBusy) BusyIndicator.show(0)`).
* Reported from app 101 by a user typing into the Wizard's Name field.
*
* Counting calls to BusyIndicator.show( ) rather than polling for the DOM
* node: the frontend calls show( ) or it does not, so the count is exact,
* while a poll between two keystrokes can miss an overlay that came and went.
* The patch lives on the module, which a round-trip does not reload, so it
* survives the view rebuilds between keystrokes.
*/
export async function watchBusyOverlay(page) {
await page.evaluate(() => {
const BI = sap.ui.require('sap/ui/core/BusyIndicator');
if (!BI) throw new Error('sap/ui/core/BusyIndicator is not loaded - cannot watch the overlay');
if (!BI.__a2ui5Watched) {
BI.__a2ui5Watched = true;
const orig = BI.show.bind(BI);
// during the self-test below the real show( ) is skipped, so proving the
// hook never puts an actual overlay on screen for the next click to hit
BI.show = (delay) => {
window.__a2ui5BusyShows += 1;
return BI.__a2ui5SelfTest ? undefined : orig(delay);
};
}
/* Prove the hook is live BEFORE anything relies on it. An assertion that
the overlay stayed down is the kind that passes when it is broken: a
watch that silently failed to attach reads zero forever, and zero is
exactly what a green run looks like. So count one deliberate call and
fail here if it does not arrive. */
BI.__a2ui5SelfTest = true;
window.__a2ui5BusyShows = 0;
BI.show(0);
const seen = window.__a2ui5BusyShows;
BI.__a2ui5SelfTest = false;
window.__a2ui5BusyShows = 0;
if (seen !== 1) {
throw new Error(`the BusyIndicator.show( ) hook did not take (counted ${seen}, expected 1) - every overlay assertion built on it would be vacuous`);
}
});
}
/* How often the GLOBAL busy indicator was raised since watchBusyOverlay( ).
Throws rather than reporting a sentinel: an unwatched page must not read as
"the overlay stayed down". */
export async function busyOverlayCount(page) {
const n = await page.evaluate(() => window.__a2ui5BusyShows);
if (typeof n !== 'number') {
throw new Error('busyOverlayCount( ) without a preceding watchBusyOverlay( ) - nothing was counted');
}
return n;
}
/* Page function for `page.waitForFunction(appRendered, undefined, { timeout })`:
* true once the APP's view has rendered, not just the framework's shell.
*
* The check used to be `[data-sap-ui]` count > 3, which the shell alone missed
* by one: ComponentContainer, the shell XMLView and its sap.m.App. abap2UI5#2913
* (2026-10-13) put a fourth element into that shell (z2ui5.core.RootSlot, which
* creates the root control at run time), so from that commit on the count was
* reached BEFORE the first roundtrip came back. The smoke went on with an empty
* page: every port without an interaction module "passed" boot and render
* without having rendered, and the overview app - the largest response of the
* corpus, so the last to arrive - failed its first click on every nightly from
* 2026-10-14 ("the control never appeared — tried 0 overflow popover(s)").
*
* What it waits for now does not count the shell's parts: a rendered port view
* is a second sap.ui.core.mvc.View next to the shell's. A start that ends on
* the framework's error overlay counts as rendered too, so the smoke still
* reports it as the fatal overlay it is rather than as a boot timeout. */
export function appRendered() {
const Element = window.sap && window.sap.ui && window.sap.ui.require
&& window.sap.ui.require('sap/ui/core/Element');
if (!Element || !Element.registry) return false;
if (/Unexpected Error Occurred/.test(document.body.innerText || '')) return true;
return Object.values(Element.registry.all())
.filter((c) => c.isA && c.isA('sap.ui.core.mvc.View') && c.getDomRef()).length >= 2;
}