Imported from abap2UI5/samples-controls (
.claude/skills/e2e-debugging/SKILL.md). Install upstream withnpx skills add abap2UI5/samples-controls --skill e2e-debugging. Copyright stays with the author.
name: e2e-debugging description: Running and debugging the Playwright e2e smoke: build freshness and stale servers, zero-size unthemed controls, overflow popovers, viewport-dependent wires, HTTPS-only device APIs, ASSERTION_FAILED runtime causes. Use when running npm run e2e, adding a meta/interactions module, closing a LIVE_TEST deviation, or when an e2e failure looks like a broken port.
E2E smoke — debugging guide
The harness itself (build, serve, run) is documented in E2E.md; the
per-port interactions live as one module each under meta/interactions/
(coverage catalogue in that directory's README, shared assertions in
scripts/lib-e2e.mjs); the e2e_smoke gate row is in the run-the-gates
guide. This guide
collects the lessons that make e2e failures readable — most "broken port"
verdicts below turned out to be harness effects.
- An e2e verdict is only as fresh as the transpiled backend —
e2e-smoke.mjsruns the code in.abap2UI5/node/output, so a port edited after the lastnpm run node:buildis NOT what the browser executes, and a leftovernode .abap2UI5/node/srv/express.mjsfrom a debug run keeps port 3000 (the harness' own spawn then fails silently and the browser talks to the stale server). The unmistakable symptom for a brand-new port isbackend HTTP 500whose body reads "The app 'Z2UI5_CL_SMPC_APP_nnn' does not exist in the system" — that is a missing rebuild, never a port defect. Never rune2e-smokewhile a build is in flight (e2e-buildwipesnode/outputfirst, so the run dies with no output). Never wait on or kill a process by grepping for a string your own command line also contains —pgrep -f e2e-buildmatches the waiting shell itself and waits forever, and agrep '[e]2e-build' | xargs killwhose command line also namese2e-build.mjskills your own shell (exit 144, no output). Grep the build log fore2e-build: done; kill by a PID noted in a SEPARATE, earlier command. - A green run that names no ports is a hollow gate — read the count.
e2e-smokeprintse2e-smoke: <n> port(s)before the first check and<n> app(s), <f> failingafter the last, and between 2026-08-28 and 2026-08-29 both read 0 on every run without--shard. The sharding slice aliased the very list it then cleared —const sharded = SHARD ? metas.filter(…) : metas;followed bymetas.length = 0; metas.push(...sharded);— sonpm run e2e,--only <class>and the unsharded--strictrun thatbump-a2ui5.yamlreports as "the strict e2e smoke over all ports" each exited 0 having checked nothing. The nightly never saw it because it always passes--shard i/4; the flagless callers carried it alone. The list is rebuilt only under--shardnow, and the lesson outlives the fix: a run you are about to trust should print the port count you expect, and a--onlyrun that reports0 port(s)is a harness bug, not a filter that missed. - A PRIVATE instance attribute 500s every roundtrip. The app's state is
persisted with
CALL TRANSFORMATION id, and the transpiled runtime's re-implementation walks the class's attributes with a dynamicASSIGN obj->(name)— which reaches a PROTECTED attribute and not a PRIVATE one.sy-subrcis then 4, the serializer asserts, and every roundtrip answersASSERTION_FAILEDfromlcl_heap.add_object(kernel_call_transformation) — with nothing in the message naming the attribute. Six ports carried it (604, 607, 617, 618, 619, 623, all a privatet_all/t_imagesmaster copy) while the 53 ports with a PROTECTED one were fine, which is what isolated it (2026-08-22). Declare app state PUBLIC and helpers PROTECTED; never PRIVATE. To find the attribute when it happens again, logls_attribute-namein front of that assert innode/output/kernel_call_transformation.clas.locals.mjs. - The transpiler HOISTS both branches of a
COND/SWITCHand evaluates them unconditionally.COND string( WHEN … THEN f( x ) ELSE g( ) )becomestemp1.set(await f(x)); temp2.set(await g()); if (…)— so a call that ABAP would never make on the taken branch runs anyway. App 609 readget_event_arg( 2 )in the THEN branch of aCONDshared by two events, and the event that carries no arguments at all asserted on the missing row: every Create press 500'd while the ABAP was correct (2026-08-22). Write the branch asIF/ELSEwhenever either side has a side effect or can fail. - The overview app's view chain overflows V8's parser stack. A view-builder
chain transpiles to ONE nested expression —
view->ele( )->a( )->end( )becomesawait (await (await (…).get().a(…)).get().a(…)), one level per call — andz2ui5_cl_smpc_app_000is 177 calls long in a 5 MB module. Node's default stack, already partly spent by the ESM loader walking the 2,340 transpiled modules, dies insidecompileSourceTextModuleand the backend never listens: the smoke reportsbackend exited (1) before listening. The harness passes--stack-size=10000on argv (NODE_OPTIONS rejects V8 options). A real system never parses this, so it is a harness limit, not a corpus one. - An internal control of the same type is the most common wrong-assertion
bug. Every
sap.m.Inputbuilds a suggestion-popupsap.m.Table, everysap.m.Breadcrumbsan emptysap.m.Link, everysap.ui.unified.CalendaraDateTypeRangeof its own — so a registry-widefilter(byType)counts one too many and a barefind(byType)can answer with the wrong control. Ask the OWNING control for its aggregation (getLinks(),getSpecialDates(),getSuggestionItems()) or address by id. Three ports read as broken on this in one sweep (2026-08-22). - A matcher this harness does not have fails as
… is not a function, and the assertion never ran.expect(locator, label)offers exactlytoBeVisible,toBeVisibleEnabled,toContainText,notToContainTextandtoHaveCountBelow— not Playwright's full set. App 582 calledtoContainand app 516toBeVisible(which did not exist until 2026-08-22); both threw before proving anything, and 516's assertion turned out to be for a control the sample never had. When adding a matcher, re-run every module that used it. - A typed binding is not written by
setValue+fireChange. The model is updated byInputBase.onChange→updateModelProperty, which runs the type and its constraints; firing the event directly leaves the CONTROL on the new value and the MODEL on the old one, so the roundtrip sends the old value (app 622, 2026-08-22). Type into[id$="<id>-inner"]and press Enter. And while a client-side constraint (sap.ui.model.type.StringwithminLength/maxLength) is violated the framework sends no roundtrip at all — measured one POST for a whole sequence, none for the Submit press — so drive the backend's own paths BEFORE putting a field into that state. - A control with no theme CSS has a zero-size box, and playwright will not
click or focus it — the e2e harness serves the UI5 sources, not the
themes, so
sapUiIcon(an Input's value-help icon, app 268) andsapMSliderHandle(apps 270/271) measure 0×0 and every actionability check fails with "not visible", which reads like a broken port — so does a growing list's "More" trigger ([id$="-trigger"], a CustomListItem with a null bounding box in the unthemed harness;dispatchMousefires it, app 422). Both have a real gesture that still goes through the control's own handling:locator.dispatchEvent('click')for an icon, andpage.evaluate(() => el.focus())+ a key press for anything else — the keyboard is the more general of the two: focus+Enterpicks aColorPaletteswatch (app 008), focus+F4opens a value help (app 233), focus+ArrowLeftmoves a slider through its two-way binding. Reach for focus+key before giving a control up. Do not "fix" this by setting the property through the UI5 API — that bypasses the binding the test exists to prove. Same family: assert the effect (a bound property, a rendered class), not the pixels (apps 207/130). - OverflowToolbar controls ARE drivable headless — open the overflow
popover first. In the harness' 1280 px viewport a toolbar folds almost
everything into its
Additional Optionsbutton, so a directgetByRole('button', …)for a toolbar control fails with "not visible". ClickingAdditional Optionsopens the associative popover and the controls inside it click normally, round-trip and all (app 174). Two details: an overflowedSegmentedButtonrenders as aSelectin that popover (app 247), and the binding template of an aggregation sits inElement.registrynext to the real rows with no binding context, so filter ongetBindingContext()before asserting a property over "all items" (app 207). - An interaction may create the state it needs — app 267's whole
breakpointChangedwire only fires below 720 px, so its interaction callspage.setViewportSize({ width: 400, height: 900 })and then waits for the boundenabledflag. A responsive wire is testable; it just needs the viewport as an input. - An external-protocol hand-off kills input for the WHOLE tab, and every
later click still reports success.
sap.m.URLHelper.redirect( )assignswindow.location.href; fortel:,sms:ormailto:headless Chromium has no handler, never commits the navigation, and from that moment delivers no further input event to the tab. Measured on app 084 (2026-08-25): after the first press, UI5's own event log shows the full mousedown/saptouchstart/mouseup/click/tap chain for item 0 and nothing afterwards, while Playwright — which dispatches through CDP — reports each later.click()as successful. The leg then dies in its own "the wire did not fire" message, indistinguishable from a dead port. The document is NOT replaced, so aframenavigatedor url check does not catch it. Neither escape from the zero-size-box rule works here: focus+Enteris swallowed the same way, and even apage.goto( )reload does not bring input back — the suppression outlives the document. So a port driving URLHelper gets exactly ONE gesture-driven leg per tab; spend it on one and fire the rest through the control's ownfirePress( ), guarded ongetType() === 'Active'andhasListeners('press')so an unwired item fails naming what it lost instead of passing silently. - Device APIs need a secure context (HTTPS) — geolocation and the camera
(
z2ui5.cc.Geolocation/CameraPicture) silently do nothing over plain HTTP;getCurrentPosition/getUserMediafail with a secure-origin error. Test over HTTPS orlocalhost, nothttp://. Network error: ASSERTION_FAILEDin a transpiled build ≠ an app defect — anASSERTcannot be caught in the JS runtime, so any assert inside aTRY … CATCH cx_rootthat a real system swallows becomes a 500 there. Two known sources, both in the runtime, not in the port: (a) the 702 downport turns a table expressiontab[ … ]intoRAISE cx_sy_itab_line_not_found, which the build maps toASSERT 1 = 0— that is why a missingget_event_arg( n )500s instead of returning initial; (b) open-abap'sCALL TRANSFORMATION id … RESULT XMLwrites character data unescaped, so an app whose model carries a<saves a draft its ownCL_IXMLcannot parse back and every later round-trip dies in the parser — patched at build time byweb/ci/patch_open_abap_xml.mjs, applied byscripts/e2e-build.mjsand by abap2UI5/mcp-server (which is why the script keeps that path even though the Pages build it was written for is gone), and forwarded upstream aspr/open-abap-xml-escaping. PreferREAD TABLEovertab[ … ]in an app that must run there.- Locate by what the DOM actually exposes, not by what the control is
called. Four shapes measured 2026-08-21, each of which fails as a plain
30s locator timeout that reads like a broken port: a Breadcrumbs link
carries
aria-labelledbypointing at ITSELF plus the current-location text, so its accessible name is not its text andgetByRole('link', { name, exact })matches nothing —getByTextdoes; a uxap ObjectPageHeaderActionButton renders icon-only and takes its accessible name from the TOOLTIP, so app 408's "toggle title" button answers to "synchronize" —pressHeaderActionresolves it through the control registry; a QuickView pageLink has no accessible name at all and its text may repeat elsewhere in the popover, so match on.sapMLnk; and the uxap header markers (-changes,-lock,-titleArrow) are internal Buttons with a generated id suffix. - A dispatched
clickis not always a press. The header markers DO get a layout box (123x22 unthemed), so a real.click()fires them while a dispatchedclickreaches the DOM node and dies there. Where a control genuinely has no box, one event may still not be enough: sap.ui.table's pointer extension acts on the mousedown/mouseup PAIR, so its 0-wide tree expand icon ignores a loneclick—dispatchMouse()sends the whole sequence. Try a real click first; dispatch only what has no box. - Several OverflowToolbars can share one page. App 357 has one on the table
and one in the footer, so "the first Additional Options button" opens the
wrong popover and the control still never shows; app 407's menu button hides
in the ToolHeader's own overflow.
revealInOverflow(page, locator)tries them in turn until the wanted control is on screen. A round-trip re-renders the toolbar and re-decides what overflows, so reveal and press TOGETHER rather than holding a locator across a round-trip. - A two-way bound live field fights the typist. Where a
liveChangewire round-trips AND the same field is bound two-way, the response echoes the server's value back and OVERWRITES anything typed since — so a fixed inter-key delay cannot fix it, only make the loss less likely (app 407: a 300ms delay swallowed the "a" and the backend filtered on "Sles").typeLive()presses one character, waits for the bound value to SETTLE on it, and retries the character if a late echo rolled it back. - Prove a missing control is the harness, not the port, by driving the UI5
API directly. App 359's row actions never render in the smoke; calling
setRowActionCount(2)+invalidate()on the table itself — bypassing the port entirely — still left every row without a_rowAction. That is what turns "the port might be broken" into "the harness cannot show this", and it belongs in the module as a comment plus a "still open" line inmeta/interactions/README.md, never a silently dropped assertion. - Prove an assertion against the DEFECT, not against itself. Temporarily
changing the expected value and watching the run fail naming what it
actually found shows the assertion reads something — worth doing (apps 084,
233, 529: "the
message>model carries ["Something wrong happened"], not the sample's Error message"), but it is the weaker half. The discriminating check is to delete the FIX from the transpiled backend —.abap2UI5/node/output/<class>.clas.mjs, the code the browser actually runs — re-run that one port, require the leg to go red with its own sentence, and restore. App 571: withthis.grouped.set(abap.builtin.abap_false)deleted from the price-ascending branch the leg presses, the run failed with exactlyFAIL 571 sorting by price while grouped did not drop the grouper and lead with Flyer; restored, green again. Apps 093/570's round-trip legs were built the same way against the old packed binding, so they cannot pass by accident. The corollary is worth saying plainly: a pass is weak evidence on its own — an enum seed only matters when the INSERT actually runs, and a conversion guard only matters on input no module types, so a green run over such a change establishes that it breaks nothing, not that the defect is provably absent. - A binding TEMPLATE answers for no row. Asserting
getVisible()on theRowActiontemplate (app 359) or on any aggregation template reads a state with no binding context — the app-207 trap in a different control. - An assertion that is already true waits for nothing. App 362 waited for an Accessories row at the head of the model after a category sort, but name-ascending already put one there: the wait returned instantly and the module raced its next round-trip against the one still in flight. Wait on the state that CHANGES.
- Type with a delay when the wire round-trips. A per-keystroke round-trip
is lossy, not queued (events fired mid-flight are dropped) — a no-delay
pressSequentiallyasserts a value the wire never promised. Full rule (app 280) in theport-a-sampleguide's porting gotchas. - A predicate that THROWS is not a predicate that is false, and the
difference is the whole diagnosis.
waitForFunctionrejects either way, so a wrapper that reports its own message for any rejection accuses the port of a defect it does not have: app 351's Remove wire read as "never shrank the bound contentAreas aggregation" for three runs while a direct dump after the same press showed three areas — the predicate was callinggetDomRef()on a control the re-render had already destroyed.waitForUi5now keeps a non-timeout reason, and the rule for the predicate is testbIsDestroyedbefore touching a control at all. - The outgoing control is still in the registry. Every round-trip rebuilds
the view, and
Element.registryholds the previous control while it is torn down — soui5All().find(…)can answer with the OLD one and its OLD state, and an assertion that the count went 4→3 fails against a 4 that no longer exists on screen. Filter on!c.bIsDestroyed && c.getDomRef(). (Going 3→4 may pass by luck, which is what makes this look like a one-sided wire bug.) - Ask what index you are counting from, and scope it. App 351's option-row
Inputs are preceded by two Inputs with an empty value, so a page-wide
.sapMInputBaseInnercounted from zero lands on one of those — and because it also reads"0", the locator passes its own starting-value check and fails later against a wire that works. Scope to the container id ([id$="mainOptions"] …). Reaching for the Element registry instead is not the fix: it holds the unbound aggregation template, and after a re-render its order is not the rows' order either. - A round-trip whose result the NEXT step needs, with nothing bound to wait
on. App 353 selects a row (
rowSelectionChange→ the backend records the index) and then presses Move; no control shows that index, so there is no bound value forwaitForUi5. The round-trip itself is observable —page.waitForResponse(r => r.request().method() === 'POST' && …)in aPromise.allwith the click. Without it the two raced and the move answered "Please select a row!", which reads exactly like a dead wire. - The RESPONSE is not the RE-RENDER.
waitForResponsetells you the backend answered; abap2UI5 rebuilds the view after that, so a locator resolved on the next line can point at a node about to be replaced. App 351's Min-Size keystroke was silently dropped that way while a dump 2.5 s after the same keystroke showed it had landed — the module read as a dead wire through four debugging rounds. Give the rebuild a moment after every round-trip, including one triggered byEnterin a bound field. fill()does not blur, and a two-way binding writes back onchange. Sofill('20')leaves the CONTROL reading 20 and the MODEL holding the old value, and the next round-trip sends the old one (app 363: no clamp, no toast, and the port looked broken). Commit withpress('Enter')— then remember that the commit is itself a round-trip, so an OverflowToolbar popover you opened to reach the field is now closed and the button you press next has to be revealed again.bIsDestroyedis not enough — check the node is still in the document. Between a round-trip's answer and the old control's teardown it is neither destroyed nor null-ref'd, just DETACHED, sofind(…)keeps handing back the previous control with its previous state. App 351 passed in isolation and failed in a full run on exactly this. Use!c.bIsDestroyed && c.getDomRef() && document.body.contains(c.getDomRef()).getItems()is answered just as happily by a control that never rendered.ui5All()isElement.registry.all()with no DOM filter of any kind (scripts/lib-e2e.mjs), so an aggregation or property read off a registry-found control proves the control EXISTS and was bound — not that it reached the screen. Where the claim is "this is on screen", put the previous bullet's filter in the same predicate, or assert something only a rendered control can produce. App 578's three table legs readgetItems().lengthoff a bareui5All()/registry find and would answer identically for a control the layout never rendered. There is nolib-e2ehelper for this yet — the filter is spelled out per module, which is exactly how it goes missing.- …but a control inside a LAZILY rendered container has no DOM and is still
correct.
sap.uxap.ObjectPageLayoutrenders its subsections lazily, sobodymay not carry a subsection's text yet, or at all, while the control is present and right. App 233's final leg scanned the body for anIllustratedMessagetitle inside auxap:ObjectPageSubSectionand was the one red app in the 623-app full-corpus run of 2026-08-26 — every wire check above it green, so the port was correct and the assertion was not. Scanning the body tested uxap's render SCHEDULING on a 623-app runner, not the port. The rule is app 108's, generalised: the filter belongs on the control whose STATE is the claim, and a control that legitimately has no DOM must only be required to EXIST — read its bound property out of the registry instead. 233's title is an expression binding over the same flag the whole no-selection state hangs on, so the property is the wire the assertion was always about, and it rides in the single registry read the module already does. - A predicate passed to
waitForUi5runs in the PAGE. It is stringified, so it cannot call another function from your module —() => sideShown() === falsefails withsideShown is not defined. Inline the whole check. And rememberwaitForUi5waits for TRUE: to assert a state is absent now, read it withpage.evaluateand compare, or the wait will sit there waiting for the very thing you meant to rule out (app 344's first draft did, and its message then described a failure the wait could never produce). - UI5 hides a grid cell with a CLASS, not inline display.
DynamicSideContent._changeGridStateaddssapUiHidden; both cells reportstyle.display === ''at every breakpoint, so a predicate reading inline display answers the same thing before and after a toggle (app 344). - An unthemed ShellBar button has no
sapFShellBar…class. It renders as a plain<button>with a generated id and the accessible name from its tooltip, sogetByRole('button', { name: 'Menu' })finds it where a class locator finds nothing and dies in a 30 s timeout (app 301). - A row selector cell has a layout box and still cannot be clicked.
sapUiTableRowSelectionCellmeasures 1264×20 in the unthemed harness (it spans the whole row instead of its narrow column) but sits in the absolutely positioned row-header layer UNDER the data cells, so every actionability check reports the pointer intercepted and.click()dies in a 30 s timeout.dispatchMouse()is the answer — the same one the zero-size-icon rule gives, for the opposite reason. - A bound aggregation stops at 100 items — that is the JSONModel default
sizeLimit, not a broken binding. The model holds all 123 mock rows whilegetSuggestionItems()/getItems()answers 100, so an assertion on the full mock row count fails against a perfectly faithful port (app 420). Assert the cap (or>= 100), and remember the original sample is capped the same way. - A BOOLEAN event arg reaches the transpiled backend as the string
'false'/'true', not as abap_bool. On a real system the framework's ajson path normalizes a JSON booleant_argtoX/space (theport-a-samplerule), but in the e2e runtime the same arg lands verbatim — soget_event_arg( ) = abap_falsenever matches, the flag never flips, and the response carries no model delta: the wire reads as dead while the port is correct (app 099 still carries the latent form; app 421 hit it live). For a wire the smoke must drive, transport a string token instead (${$parameters>/isTopPage} ? 'top' : 'sub') — deterministic on both runtimes. The divergence itself belongs upstream (open-abap/ajson boolean node handling); file it in the abap2UI5 backlog when touching this next.