Skip to content

Commit 5aacaf1

Browse files
rubenvdlindeConduction Release Botclaude
authored
fix(e2e): the chrome destinations are a named gap, not a mystery failure (#549)
LaunchPad declares nine pages in its manifest and serves ONE. /store, /reports and /flows each redirect to /dashboard, and /reports/dashboards renders the workspace grid. That is not a bug in these tests. It is the app: LaunchPad has no vue-router at all — createRouter appears nowhere in src/ — because navigation is Pinia state that never touches the URL, and it is the only app in the fleet that does not root on CnAppRoot. main.js has called this Tier 1 since manifest adoption landed and named the change that would fix it. That change was never filed, so it is filed here: openspec/changes/launchpad-manifest-tier-3/. The four route-dependent assertions are marked test.fail() against it. Not skipped: a skipped test proves nothing and stops being read, while Playwright FAILS the run when a test.fail() test passes — so landing routing turns these red until the markers come off, in the change that earns it. What stays a live assertion is the half that is true today and worth guarding: the manifest declares four footer destinations in ADR-114's order, each with an icon, Documentation as an external href, and each of the other three naming a page the manifest hosts. The admin settings section splits out of the old foldout test and keeps passing, because it is a Nextcloud settings route rather than one of this app's own. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7f89447 commit 5aacaf1

3 files changed

Lines changed: 185 additions & 21 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# LaunchPad manifest tier 3 — serve the pages the manifest declares
2+
3+
`src/manifest.json` declares nine pages and eight menu entries. LaunchPad
4+
serves one. `/store`, `/reports` and `/flows` each redirect to `/dashboard`,
5+
and `/reports/dashboards` renders the workspace grid.
6+
7+
This change gives LaunchPad the routing its manifest already assumes, so that a
8+
declared destination is a reachable one.
9+
10+
## Why now
11+
12+
`main.js` has named this change since Tier 1 adoption landed:
13+
14+
> Tier 1 manifest adoption (ADR-024): register the bundled manifest with nc-vue
15+
> so the shared shell can read menu/page declarations. The vue-router definition
16+
> below remains hand-wired (Tier 1 — not yet manifest-driven).
17+
> **Tier 3 (launchpad-manifest-tier-3) will replace hand-wired routes.**
18+
19+
It was never filed, and in the meantime three things arrived that assume it:
20+
21+
- **ADR-114 app chrome.** `feat(chrome): give launchpad a Store` (2026-09-04)
22+
added the fourth footer destination and took gate-107 from 4 of 5 to 5 of 5.
23+
gate-107 reads the manifest. It cannot see that nothing routes what the
24+
manifest declares.
25+
- **An E2E spec that has never passed.** `tests/e2e/app-chrome.spec.ts` landed
26+
2026-09-03 and that is the run in which LaunchPad first went red. It has not
27+
been green since. It asserted `[data-testid="cn-nav"]`, which this app does
28+
not render; retargeting it to `.workspace-shell` (#547) only moved the
29+
failure onto the true cause — the destinations do not resolve.
30+
- **Four cards in the setup wizard's own chrome.** Documentation is an external
31+
href and works. Store, Reports and Features & roadmap are pages this app
32+
claims to host.
33+
34+
## The shape of the problem
35+
36+
LaunchPad is the only app in the fleet that does not root on `CnAppRoot`, and
37+
it has **no vue-router at all**`createRouter` appears nowhere in `src/`.
38+
Navigation is Pinia state: `dashboard.js` and `orgNavigation.js` switch what
39+
the grid shows, in place, without touching the URL.
40+
41+
That is not an oversight. `App.vue` records the decision where it writes its own
42+
skip link, because not rooting on `NcContent`/`CnAppRoot` means not inheriting
43+
Nextcloud's. `WorkspaceApp.vue` owns five regions — sidebar backdrop, hamburger
44+
strip, grid surface, branded `DashboardFooter` — and `#launchpad-main-content`
45+
carries both the WCAG 2.2 bypass target and the quick-search Esc contract.
46+
47+
So this is not "add a router". It is "give a single-surface app a second
48+
surface without breaking the first".
49+
50+
## Affected code units
51+
52+
- `src/main.js` — create a `vue-router` instance in path mode and mount it;
53+
replace the `useAppManifest` Tier 1 registration with the manifest-driven
54+
route table nc-vue derives from `manifest.pages`.
55+
- `src/App.vue` — root on `CnAppRoot` so the shared chrome renders the four
56+
footer destinations, with `WorkspaceApp` as the dashboard route's component
57+
rather than the app's only child. The skip link moves to whichever shell ends
58+
up owning it; it must not be dropped.
59+
- `src/views/WorkspaceApp.vue` — becomes the `/dashboard` route's view. Its
60+
five regions stay; `#launchpad-main-content` keeps `tabindex="-1"`.
61+
- `src/manifest.json` — no new declarations. This change makes the existing
62+
ones true.
63+
- `tests/e2e/app-chrome.spec.ts` — the four `test.fail()` markers this change
64+
is named in come off, and the assertions become ordinary ones.
65+
66+
## Decisions needed
67+
68+
- **Does `CnAppRoot` host the workspace, or the other way round?** The org
69+
navigation rail and the slide-in sidebar are LaunchPad's own and have no
70+
equivalent in `CnAppNav`. Nesting the workspace inside `CnAppRoot`'s content
71+
area gives two navigation systems on one page unless the rail is reconciled
72+
with the nav.
73+
- **The skip link.** `CnAppRoot` brings Nextcloud's; LaunchPad writes its own
74+
against `#launchpad-main-content`. Two bypass links is worse than one.
75+
- **Whether the dashboard keeps its URL.** Dashboards switch in Pinia today, so
76+
a dashboard has no address. `dashboard-deeplinking` (an existing spec) wants
77+
one. Routing is the natural place to settle that, and it widens this change.
78+
79+
## Alternative considered
80+
81+
**Make LaunchPad a documented ADR-114 exception**: drop the pages it cannot
82+
serve from the manifest, move the chrome into `DashboardFooter`, and record the
83+
exception in ADR-114 so gate-107 stops reporting a chrome that is not there.
84+
85+
Rejected as the default because every other app in the fleet roots on
86+
`CnAppRoot`, and the manifest already declares the pages — the cheaper change
87+
is to make the declaration true rather than to withdraw it. Worth revisiting if
88+
the decisions above turn out to cost more than the chrome is worth.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Tasks: LaunchPad manifest tier 3
2+
3+
## Decide first
4+
5+
- [ ] Settle whether `CnAppRoot` hosts `WorkspaceApp` or the workspace shell keeps the page and only borrows the footer chrome.
6+
- [ ] Settle the skip link: one bypass target, not two.
7+
- [ ] Settle whether a dashboard gets a URL, and whether that is this change or `dashboard-deeplinking`.
8+
9+
## Routing
10+
11+
- [ ] Add `vue-router` and create the router in `src/main.js` in **path** mode (`createWebHistory`), matching the fleet: a hash route would be ignored and land silently on the dashboard.
12+
- [ ] Derive the route table from `manifest.pages` rather than hand-wiring it, so a declared page is a routed page by construction.
13+
- [ ] `/dashboard` (and `/`) render `WorkspaceApp`; `#launchpad-main-content` keeps `tabindex="-1"` and stays the bypass target.
14+
- [ ] `/store`, `/reports`, `/reports/dashboards` and `/features-roadmap` render their declared page types.
15+
16+
## Shell
17+
18+
- [ ] Root on `CnAppRoot` per the decision above, keeping the org navigation rail and slide-in sidebar working.
19+
- [ ] One skip link.
20+
- [ ] `.workspace-shell` still renders on the dashboard route — the chrome spec's tripwire test asserts the shell, and it should keep passing.
21+
22+
## Tests
23+
24+
- [ ] Remove the four `test.fail()` markers in `tests/e2e/app-chrome.spec.ts`. They are expected-to-fail today; Playwright fails the run if a `test.fail()` test passes, so landing routing turns them red until the markers come off. That is the intended signal.
25+
- [ ] The tripwire test `the shell is LaunchPad's own, not the shared CnAppNav one` asserts `cn-nav` has count 0. If `CnAppRoot` is adopted it must be rewritten, not deleted.
26+
- [ ] A route test per declared page, so a page added to the manifest without a component fails.
27+
28+
## Verify
29+
30+
- [ ] gate-107 still reports 5 of 5, and now truthfully.
31+
- [ ] The E2E leg is green on the `development` push run — it has not been since 2026-09-03.

tests/e2e/app-chrome.spec.ts

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,7 @@ test.describe('app chrome (ADR-114)', () => {
9292
).toHaveCount(0)
9393
})
9494

95-
test('the chrome declares Documentation, Store, Reports and Features & roadmap, and each destination resolves', async ({
96-
page,
97-
}) => {
95+
test('the chrome declares Documentation, Store, Reports and Features & roadmap', async () => {
9896
// The manifest is read here rather than restated, so a renamed or
9997
// dropped entry is a failure instead of a silently stale literal.
10098

@@ -131,21 +129,48 @@ test.describe('app chrome (ADR-114)', () => {
131129
(manifest.pages ?? []).map((p: any) => [p.id, p.route]),
132130
)
133131
for (const entry of footer.slice(1)) {
134-
const route = pages.get(entry.route)
135132
expect(
136-
route,
133+
pages.get(entry.route),
137134
`${entry.label} names page "${entry.route}", which this app does not host`,
138135
).toBeTruthy()
136+
}
137+
})
138+
139+
test('each declared chrome destination opens', async ({ page }) => {
140+
// 🔴 EXPECTED TO FAIL, AND FILED. LaunchPad declares nine pages and
141+
// serves ONE: `/store`, `/reports` and `/flows` each redirect to
142+
// `/dashboard`. It has no vue-router at all — `createRouter` appears
143+
// nowhere in src/ — because navigation is Pinia state that never
144+
// touches the URL. `main.js` calls this Tier 1 and names the change
145+
// that fixes it; that change is now filed at
146+
// `openspec/changes/launchpad-manifest-tier-3/`.
147+
//
148+
// `test.fail()` rather than a skip, deliberately: a skipped test proves
149+
// nothing and quietly stops being read, while this one FAILS THE RUN
150+
// the moment routing lands. That is the notification we want — the
151+
// marker comes off in the same change that makes it pass.
152+
test.fail()
153+
154+
const manifest = JSON.parse(
155+
// eslint-disable-next-line @typescript-eslint/no-require-imports
156+
require('fs').readFileSync(
157+
// eslint-disable-next-line @typescript-eslint/no-require-imports
158+
require('path').resolve(__dirname, '../../src/manifest.json'),
159+
'utf-8',
160+
),
161+
)
162+
const footer = (manifest.menu ?? [])
163+
.filter((e: any) => e.section === 'footer' && e.route)
164+
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0))
165+
const pages = new Map(
166+
(manifest.pages ?? []).map((p: any) => [p.id, p.route]),
167+
)
139168

140-
// AND IT RENDERS. A row that goes nowhere is the failure mode a
141-
// manifest gate cannot see, so each destination is opened.
169+
for (const entry of footer) {
170+
const route = pages.get(entry.route)
142171
await page.goto(`${APP_BASE}${route}`, {
143172
waitUntil: 'domcontentloaded',
144173
})
145-
await expect(
146-
page.locator('.workspace-shell'),
147-
`${entry.label} (${route}) did not render the app shell`,
148-
).toBeVisible({ timeout: 30_000 })
149174
await expect(page).toHaveURL(new RegExp(`${route}(\\?|$)`), {
150175
timeout: 15_000,
151176
})
@@ -155,12 +180,16 @@ test.describe('app chrome (ADR-114)', () => {
155180
test('Reports lists the one report this app can honestly offer', async ({
156181
page,
157182
}) => {
183+
// 🔴 EXPECTED TO FAIL UNTIL ROUTING LANDS — see
184+
// `openspec/changes/launchpad-manifest-tier-3/`. This app serves one
185+
// page; the route below redirects to `/dashboard`. `test.fail()` rather
186+
// than a skip, so the run goes red the moment it starts passing.
187+
test.fail()
188+
158189
// One card, deliberately. LaunchPad's register holds a single schema —
159190
// dashboard — so a second report would either repeat this one or invent
160191
// a reading the data cannot support. If a schema is added later and no
161192
// report follows, this count is what notices.
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).
164193
await page.goto(`${APP_BASE}/reports`, { waitUntil: 'domcontentloaded' })
165194
await expect(page).toHaveURL(/\/apps\/launchpad\/reports(\?|$)/, {
166195
timeout: 15_000,
@@ -177,6 +206,12 @@ test.describe('app chrome (ADR-114)', () => {
177206
test('the dashboards report renders real numbers, not empty cards', async ({
178207
page,
179208
}) => {
209+
// 🔴 EXPECTED TO FAIL UNTIL ROUTING LANDS — see
210+
// `openspec/changes/launchpad-manifest-tier-3/`. This app serves one
211+
// page; the route below redirects to `/dashboard`. `test.fail()` rather
212+
// than a skip, so the run goes red the moment it starts passing.
213+
test.fail()
214+
180215
await page.goto(`${APP_BASE}/reports/dashboards`)
181216
await expect(page.locator('.workspace-shell')).toBeVisible({
182217
timeout: 30_000,
@@ -193,6 +228,12 @@ test.describe('app chrome (ADR-114)', () => {
193228
test('Store opens the hosted store surface, which this app writes no backend for', async ({
194229
page,
195230
}) => {
231+
// 🔴 EXPECTED TO FAIL UNTIL ROUTING LANDS — see
232+
// `openspec/changes/launchpad-manifest-tier-3/`. This app serves one
233+
// page; the route below redirects to `/dashboard`. `test.fail()` rather
234+
// than a skip, so the run goes red the moment it starts passing.
235+
test.fail()
236+
196237
await page.goto(`${APP_BASE}/store`, { waitUntil: 'domcontentloaded' })
197238

198239
await expect(page).toHaveURL(/\/apps\/launchpad\/store(\?|$)/, {
@@ -208,27 +249,31 @@ test.describe('app chrome (ADR-114)', () => {
208249
})
209250
})
210251

211-
test('admin settings and Flows are reachable, which is what the foldout was for', async ({
212-
page,
213-
}) => {
252+
test('the admin settings section renders', async ({ page }) => {
214253
// ⚠️ NOT A FOLDOUT TEST ANY MORE, and it cannot be. The settings
215254
// foldout is `CnAppNav`'s, and this app renders no CnAppNav — the
216255
// personal-settings entry in particular is a nav widget with no
217256
// 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.
257+
// about it. What survives is the half that is about LaunchPad rather
258+
// than about the nav component, and it is a Nextcloud settings route
259+
// rather than one of this app's own, which is why it still passes.
220260
await page.goto('/settings/admin/launchpad', {
221261
waitUntil: 'domcontentloaded',
222262
})
223263
await expect(
224264
page.locator('#app-content, main').first(),
225265
'the admin settings section did not render',
226266
).toBeVisible({ timeout: 30_000 })
267+
})
268+
269+
test('the Flows page opens', async ({ page }) => {
270+
// 🔴 EXPECTED TO FAIL UNTIL ROUTING LANDS — see
271+
// `openspec/changes/launchpad-manifest-tier-3/`. This app serves one
272+
// page; the route below redirects to `/dashboard`. `test.fail()` rather
273+
// than a skip, so the run goes red the moment it starts passing.
274+
test.fail()
227275

228276
await page.goto(`${APP_BASE}/flows`, { waitUntil: 'domcontentloaded' })
229-
await expect(page.locator('.workspace-shell')).toBeVisible({
230-
timeout: 30_000,
231-
})
232277
await expect(page).toHaveURL(/\/apps\/launchpad\/flows(\?|$)/, {
233278
timeout: 15_000,
234279
})

0 commit comments

Comments
 (0)