1818 * its title and no value, silently. So the assertion below looks for a VALUE,
1919 * not just for the card.
2020 *
21- * ⚠️ SCOPE EVERY SELECTOR TO `[data-testid="cn-nav"]`. An unscoped selector
22- * also matches Nextcloud's own user menu, which is attached-but-hidden:
23- * `waitFor({state:'attached'})` passes on it and the click never becomes
24- * actionable, so the spec fails with "Target page has been closed" — a timeout
25- * wearing a crash's clothes .
21+ * 🔴 LAUNCHPAD DOES NOT RENDER `CnAppNav`, AND THAT IS DELIBERATE. It does not
22+ * root on `CnAppRoot`/`NcContent`; `App.vue` says so and writes its own
23+ * `.workspace-shell` (org navigation rail, slide-in sidebar, branded
24+ * DashboardFooter) with its own skip link. So there is no
25+ * `[data-testid="cn-nav"]` and no `.cn-app-nav__footer-list` on any page .
2626 *
27- * ⚠️ SETTINGS ENTRIES ARE ATTACHED, NOT VISIBLE, inside a collapsed foldout.
27+ * These five tests asserted both, from the commit that gave this app a Store
28+ * (2026-09-04) onward, and the E2E leg has been red on every push since: a
29+ * `beforeEach` waiting 30 s for a nav that cannot appear, reported as five
30+ * broken features.
31+ *
32+ * What the chrome IS here: four destinations the manifest declares in its
33+ * `footer` section — Documentation (an external href), Store, Reports and
34+ * Features & roadmap — each of which must resolve to a page this app hosts.
35+ * That is what these tests check now: the shell it renders, and the
36+ * destinations it declares, by route rather than by a nav entry that does not
37+ * exist. A gate can prove the entries are DECLARED; only a browser can prove
38+ * the destinations RENDER, which is what the failure modes above are about.
39+ *
40+ * ⚠️ IF LAUNCHPAD EVER ADOPTS `CnAppRoot`, the first test below fails on
41+ * purpose: it asserts the absence, so the adoption cannot land silently while
42+ * these tests keep passing against a shell that is no longer there.
2843 */
2944
3045import type { Page } from '@playwright/test'
@@ -56,41 +71,84 @@ async function dismissSetupWizard(page: Page): Promise<void> {
5671test . describe ( 'app chrome (ADR-114)' , ( ) => {
5772 test . beforeEach ( async ( { page } ) => {
5873 await page . goto ( `${ APP_BASE } /` , { waitUntil : 'domcontentloaded' } )
59- await expect ( page . locator ( '[data-testid="cn-nav"] ' ) ) . toBeVisible ( {
74+ await expect ( page . locator ( '.workspace-shell ' ) ) . toBeVisible ( {
6075 timeout : 30_000 ,
6176 } )
6277 await dismissSetupWizard ( page )
6378 } )
6479
65- test ( 'the footer reads Documentation, Store, Reports, Features & roadmap, each with a glyph' , async ( {
80+ test ( "the shell is LaunchPad's own, not the shared CnAppNav one" , async ( {
81+ page,
82+ } ) => {
83+ // The premise every other test here rests on, asserted rather than
84+ // assumed. It is also the tripwire: an app that quietly starts rooting
85+ // on CnAppRoot fails HERE, where the reason is written down, instead of
86+ // leaving four tests passing against chrome that moved.
87+ await expect ( page . locator ( '.workspace-shell' ) ) . toBeVisible ( )
88+ await expect ( page . locator ( '#launchpad-main-content' ) ) . toBeAttached ( )
89+ await expect (
90+ page . locator ( '[data-testid="cn-nav"]' ) ,
91+ 'LaunchPad now renders CnAppNav — the chrome tests below should assert it instead of its own shell' ,
92+ ) . toHaveCount ( 0 )
93+ } )
94+
95+ test ( 'the chrome declares Documentation, Store, Reports and Features & roadmap, and each destination resolves' , async ( {
6696 page,
6797 } ) => {
68- const footer = page . locator (
69- '[data-testid="cn-nav"] .cn-app-nav__footer-list' ,
98+ // The manifest is read here rather than restated, so a renamed or
99+ // dropped entry is a failure instead of a silently stale literal.
100+
101+ const manifest = JSON . parse (
102+ // eslint-disable-next-line @typescript-eslint/no-require-imports
103+ require ( 'fs' ) . readFileSync (
104+ // eslint-disable-next-line @typescript-eslint/no-require-imports
105+ require ( 'path' ) . resolve ( __dirname , '../../src/manifest.json' ) ,
106+ 'utf-8' ,
107+ ) ,
70108 )
71- await expect ( footer ) . toBeAttached ( { timeout : 15_000 } )
109+ const footer = ( manifest . menu ?? [ ] )
110+ . filter ( ( e : any ) => e . section === 'footer' )
111+ . sort ( ( a : any , b : any ) => ( a . order ?? 0 ) - ( b . order ?? 0 ) )
112+
113+ expect (
114+ footer . map ( ( e : any ) => e . label ) ,
115+ 'ADR-114 declares four footer destinations, in this order' ,
116+ ) . toEqual ( [ 'Documentation' , 'Store' , 'Reports' , 'Features & roadmap' ] )
117+
118+ // A GLYPH ON EVERY ONE. An icon name that is not registered renders no
119+ // glyph — not a fallback, not a console error; this app shipped one.
120+ for ( const entry of footer ) {
121+ expect ( entry . icon , `${ entry . label } declares no icon` ) . toBeTruthy ( )
122+ }
72123
73- const rows = footer . locator ( 'li' )
74- const texts = ( await rows . allInnerTexts ( ) )
75- . map ( ( t ) => t . trim ( ) )
76- . filter ( Boolean )
124+ // Documentation leaves the app, so it is an href and there is nothing
125+ // here to render. The other three name a page this app must host.
126+ expect ( footer [ 0 ] . href , 'Documentation must be an external href' ) . toMatch (
127+ / ^ h t t p s : \/ \/ / ,
128+ )
77129
78- const seen = texts . filter ( ( t ) =>
79- / D o c u m e n t a t i o n | S t o r e | R e p o r t s | r o a d m a p / i . test ( t ) ,
130+ const pages = new Map (
131+ ( manifest . pages ?? [ ] ) . map ( ( p : any ) => [ p . id , p . route ] ) ,
80132 )
81- expect ( seen . length ) . toBe ( 4 )
82- expect ( seen [ 0 ] ) . toMatch ( / D o c u m e n t a t i o n / i)
83- expect ( seen [ 1 ] ) . toMatch ( / S t o r e / i)
84- expect ( seen [ 2 ] ) . toMatch ( / R e p o r t s / i)
85- expect ( seen [ 3 ] ) . toMatch ( / r o a d m a p / i)
86-
87- // A glyph on every row. This app registers only a handful of icons, and
88- // ChartBoxOutline had to be added for the Reports entry; without it the
89- // row renders a blank space and nothing complains.
90- for ( const row of await rows . all ( ) ) {
133+ for ( const entry of footer . slice ( 1 ) ) {
134+ const route = pages . get ( entry . route )
135+ expect (
136+ route ,
137+ `${ entry . label } names page "${ entry . route } ", which this app does not host` ,
138+ ) . toBeTruthy ( )
139+
140+ // AND IT RENDERS. A row that goes nowhere is the failure mode a
141+ // manifest gate cannot see, so each destination is opened.
142+ await page . goto ( `${ APP_BASE } ${ route } ` , {
143+ waitUntil : 'domcontentloaded' ,
144+ } )
91145 await expect (
92- row . locator ( 'svg, .material-design-icon' ) . first ( ) ,
93- ) . toBeAttached ( )
146+ page . locator ( '.workspace-shell' ) ,
147+ `${ entry . label } (${ route } ) did not render the app shell` ,
148+ ) . toBeVisible ( { timeout : 30_000 } )
149+ await expect ( page ) . toHaveURL ( new RegExp ( `${ route } (\\?|$)` ) , {
150+ timeout : 15_000 ,
151+ } )
94152 }
95153 } )
96154
@@ -101,11 +159,9 @@ test.describe('app chrome (ADR-114)', () => {
101159 // dashboard — so a second report would either repeat this one or invent
102160 // a reading the data cannot support. If a schema is added later and no
103161 // report follows, this count is what notices.
104- const nav = page . locator ( '[data-testid="cn-nav"]' )
105- await nav
106- . locator ( '[data-testid="cn-nav-entry-ReportsMenu"] a' )
107- . first ( )
108- . click ( )
162+ // By route: the Reports destination is declared in the manifest's footer
163+ // section, and this app renders no nav entry to click (see the top).
164+ await page . goto ( `${ APP_BASE } /reports` , { waitUntil : 'domcontentloaded' } )
109165 await expect ( page ) . toHaveURL ( / \/ a p p s \/ l a u n c h p a d \/ r e p o r t s ( \? | $ ) / , {
110166 timeout : 15_000 ,
111167 } )
@@ -122,7 +178,7 @@ test.describe('app chrome (ADR-114)', () => {
122178 page,
123179 } ) => {
124180 await page . goto ( `${ APP_BASE } /reports/dashboards` )
125- await expect ( page . locator ( '[data-testid="cn-nav"] ' ) ) . toBeVisible ( {
181+ await expect ( page . locator ( '.workspace-shell ' ) ) . toBeVisible ( {
126182 timeout : 30_000 ,
127183 } )
128184 await expect (
@@ -137,13 +193,7 @@ test.describe('app chrome (ADR-114)', () => {
137193 test ( 'Store opens the hosted store surface, which this app writes no backend for' , async ( {
138194 page,
139195 } ) => {
140- const footer = page . locator (
141- '[data-testid="cn-nav"] .cn-app-nav__footer-list' ,
142- )
143- await footer
144- . getByRole ( 'link' , { name : / ^ S t o r e $ / } )
145- . first ( )
146- . click ( )
196+ await page . goto ( `${ APP_BASE } /store` , { waitUntil : 'domcontentloaded' } )
147197
148198 await expect ( page ) . toHaveURL ( / \/ a p p s \/ l a u n c h p a d \/ s t o r e ( \? | $ ) / , {
149199 timeout : 15_000 ,
@@ -153,29 +203,34 @@ test.describe('app chrome (ADR-114)', () => {
153203 // app ships NO store controller (ADR-080, ADR-114 Decision 4). With no
154204 // registry configured it renders the app's own items and makes NO
155205 // network call, so this must pass on a plain instance.
156- await expect ( page . locator ( '[data-testid="cn-nav"]' ) ) . toBeVisible ( )
206+ await expect ( page . locator ( '.workspace-shell' ) ) . toBeVisible ( {
207+ timeout : 30_000 ,
208+ } )
157209 } )
158210
159- test ( 'the settings foldout carries Personal settings, Admin settings and Flows ' , async ( {
211+ test ( 'admin settings and Flows are reachable, which is what the foldout was for ' , async ( {
160212 page,
161213 } ) => {
162- const nav = page . locator ( '[data-testid="cn-nav"]' )
214+ // ⚠️ NOT A FOLDOUT TEST ANY MORE, and it cannot be. The settings
215+ // foldout is `CnAppNav`'s, and this app renders no CnAppNav — the
216+ // personal-settings entry in particular is a nav widget with no
217+ // equivalent in `.workspace-shell`, so there is nothing here to assert
218+ // about it. What survives is the part that is about LaunchPad rather
219+ // than about the nav component: the two destinations exist and open.
220+ await page . goto ( '/settings/admin/launchpad' , {
221+ waitUntil : 'domcontentloaded' ,
222+ } )
223+ await expect (
224+ page . locator ( '#app-content, main' ) . first ( ) ,
225+ 'the admin settings section did not render' ,
226+ ) . toBeVisible ( { timeout : 30_000 } )
163227
164- await expect ( nav . locator ( '[data-testid="cn-nav-settings"]' ) ) . toBeAttached ( {
228+ await page . goto ( `${ APP_BASE } /flows` , { waitUntil : 'domcontentloaded' } )
229+ await expect ( page . locator ( '.workspace-shell' ) ) . toBeVisible ( {
230+ timeout : 30_000 ,
231+ } )
232+ await expect ( page ) . toHaveURL ( / \/ a p p s \/ l a u n c h p a d \/ f l o w s ( \? | $ ) / , {
165233 timeout : 15_000 ,
166234 } )
167- await expect (
168- nav . locator ( '[data-testid="cn-nav-personal-settings"]' ) ,
169- ) . toBeAttached ( )
170- await expect (
171- nav . locator ( '[data-testid="cn-nav-entry-FlowsMenu"]' ) ,
172- ) . toBeAttached ( )
173-
174- const admin = nav . locator ( '[data-testid="cn-nav-admin-settings"]' )
175- await expect ( admin ) . toBeAttached ( )
176- await expect ( admin . locator ( 'a' ) . first ( ) ) . toHaveAttribute (
177- 'href' ,
178- / \/ s e t t i n g s \/ a d m i n \/ l a u n c h p a d $ / ,
179- )
180235 } )
181236} )
0 commit comments