@igstack/app-catalog-frontend-core 0.10.1 → 0.11.0

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.
Files changed (88) hide show
  1. package/dist/esm/__tests__/integration/harness/given.d.ts +1 -0
  2. package/dist/esm/__tests__/integration/skewWarning.integration.test.d.ts +0 -0
  3. package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +5 -0
  4. package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +15 -1
  5. package/dist/esm/__tests__/modules/appCatalog/utils/resolveHelpers.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.d.ts +40 -0
  7. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js +40 -0
  8. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js.map +1 -0
  9. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +17 -0
  10. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +61 -0
  11. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -0
  12. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +9 -2
  13. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js +1 -1
  15. package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +9 -1
  17. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -3
  18. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +3 -2
  20. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +5 -0
  22. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +56 -10
  23. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +12 -0
  25. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +48 -0
  26. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.d.ts +19 -0
  28. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +355 -0
  29. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.d.ts +13 -0
  31. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js +45 -0
  32. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/launcher/monogram.d.ts +4 -0
  34. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js +41 -0
  35. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js.map +1 -0
  36. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +40 -6
  37. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  38. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
  39. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
  40. package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +10 -0
  41. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +17 -0
  42. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
  43. package/dist/esm/routeTree.gen.d.ts +1 -1
  44. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  45. package/dist/esm/routes/_layout/app._slug.js +4 -3
  46. package/dist/esm/routes/_layout/app._slug.js.map +1 -1
  47. package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
  48. package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
  49. package/dist/esm/routes/_layout/index.js +2 -1
  50. package/dist/esm/routes/_layout/index.js.map +1 -1
  51. package/dist/esm/routes/_layout/service-desks.js +2 -1
  52. package/dist/esm/routes/_layout/service-desks.js.map +1 -1
  53. package/dist/esm/ui/components/header/Header.js +1 -1
  54. package/dist/esm/ui/components/header/Header.js.map +1 -1
  55. package/dist/index.css +73 -66
  56. package/package.json +4 -4
  57. package/src/__tests__/integration/appCatalog.integration.test.ts +106 -0
  58. package/src/__tests__/integration/appDeepLink.integration.test.ts +50 -24
  59. package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
  60. package/src/__tests__/integration/harness/given.tsx +9 -1
  61. package/src/__tests__/integration/replacementLink.integration.test.ts +6 -2
  62. package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +21 -16
  63. package/src/__tests__/integration/setup/testSetup.ts +1 -0
  64. package/src/__tests__/integration/skewWarning.integration.test.ts +56 -0
  65. package/src/__tests__/integration/tools/AppDetailTools.ts +13 -0
  66. package/src/__tests__/integration/tools/CatalogTools.ts +60 -11
  67. package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +5 -5
  68. package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +79 -0
  69. package/src/index.css +73 -66
  70. package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +83 -0
  71. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +89 -0
  72. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +31 -2
  73. package/src/modules/appCatalog/ui/components/OnboardingCard.tsx +1 -1
  74. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +22 -1
  75. package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +11 -5
  76. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +68 -10
  77. package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +55 -0
  78. package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +497 -0
  79. package/src/modules/appCatalog/ui/launcher/ResourceIcon.tsx +54 -0
  80. package/src/modules/appCatalog/ui/launcher/monogram.ts +41 -0
  81. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +75 -13
  82. package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
  83. package/src/modules/appCatalog/utils/resolveHelpers.ts +31 -0
  84. package/src/routes/_layout/app.$slug.tsx +7 -4
  85. package/src/routes/_layout/catalog.apps.index.tsx +4 -1
  86. package/src/routes/_layout/index.tsx +5 -1
  87. package/src/routes/_layout/service-desks.tsx +4 -1
  88. package/src/ui/components/header/Header.tsx +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -134,8 +134,8 @@
134
134
  "vite-plugin-static-copy": "^3.1.4",
135
135
  "vite-plugin-svgr": "^4.2.0",
136
136
  "vitest": "^4.1.2",
137
- "@igstack/app-catalog-backend-core": "0.10.1",
138
- "@igstack/app-catalog-shared-core": "0.10.1"
137
+ "@igstack/app-catalog-backend-core": "0.11.0",
138
+ "@igstack/app-catalog-shared-core": "0.11.0"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "react": "19.1.2",
@@ -144,7 +144,7 @@
144
144
  "vite": "^6.3.5",
145
145
  "vite-plugin-svgr": "^4.2.0"
146
146
  },
147
- "gitHead": "3fd62ed2e0a2934d1f07f66cda16afb05f528515",
147
+ "gitHead": "cbba8555258bdf8df2df78a8bcd259cc3361a6ba",
148
148
  "scripts": {
149
149
  "build": "vite build",
150
150
  "build:lenient": "vite build --mode lenient",
@@ -71,6 +71,83 @@ describe('App Catalog Integration', () => {
71
71
  expect(appData.description).toBe('A test application')
72
72
  })
73
73
 
74
+ // Access UX (#31): "no access required" must be stated, not implied by a
75
+ // blank section. Previously a noAccessRequired/custom method rendered nothing.
76
+ it('states "open to everyone" for a no-access-required app', async () => {
77
+ const { ui } = await given(
78
+ magazine.custom(({ backendCfg }) => {
79
+ const method = backendCfg.withApprovalMethod({
80
+ type: 'noAccessRequired',
81
+ displayName: 'No Approval Needed',
82
+ config: {},
83
+ })
84
+ backendCfg.withApp({
85
+ displayName: 'Open App',
86
+ description: 'Anyone can use it',
87
+ accessRequest: { approvalMethodSlug: method.slug },
88
+ })
89
+ }),
90
+ )
91
+
92
+ await ui.catalog.openApp('Open App')
93
+ const access = ui.app.getAccessText()
94
+ expect(access).not.toBeNull()
95
+ expect(access!.toLowerCase()).toContain('no request')
96
+ })
97
+
98
+ // Access UX (#31): an undocumented method must route the user to the owner,
99
+ // never leave a blank/dead access section.
100
+ it('offers a fallback when the access process is undocumented', async () => {
101
+ const { ui } = await given(
102
+ magazine.custom(({ backendCfg }) => {
103
+ const method = backendCfg.withApprovalMethod({
104
+ type: 'unknown',
105
+ displayName: 'Unknown',
106
+ config: {},
107
+ })
108
+ backendCfg.withApp({
109
+ displayName: 'Mystery App',
110
+ description: 'Access process not written down',
111
+ accessRequest: { approvalMethodSlug: method.slug },
112
+ })
113
+ }),
114
+ )
115
+
116
+ await ui.catalog.openApp('Mystery App')
117
+ const access = ui.app.getAccessText()
118
+ expect(access).not.toBeNull()
119
+ expect(access!.toLowerCase()).toContain('not')
120
+ })
121
+
122
+ // Access UX (#31): each row has a secondary "open in new tab" launch action
123
+ // (the primary click opens detail). The launch link points at the app URL and
124
+ // must not also open the detail panel.
125
+ it('offers a secondary launch link on rows that has the app url', async () => {
126
+ const { ui } = await given(
127
+ magazine.custom(({ backendCfg }) => {
128
+ backendCfg.withApp({
129
+ displayName: 'Launchable App',
130
+ description: 'Has a url',
131
+ appUrl: 'https://launch.example.com',
132
+ })
133
+ // Second app so the single-result auto-open doesn't fire.
134
+ backendCfg.withApp({
135
+ displayName: 'Other App',
136
+ description: 'No url here',
137
+ })
138
+ }),
139
+ )
140
+
141
+ const link = ui.catalog.getLaunchLink('Launchable App')
142
+ expect(link).not.toBeNull()
143
+ expect(link!.getAttribute('href')).toBe('https://launch.example.com')
144
+ expect(link!.getAttribute('target')).toBe('_blank')
145
+ // The row without a URL has no launch link.
146
+ expect(ui.catalog.getLaunchLink('Other App')).toBeNull()
147
+ // Launch link is a secondary action — it does not open the detail panel.
148
+ expect(ui.catalog.isDetailPanelOpen()).toBe(false)
149
+ })
150
+
74
151
  // Test 5: Returning user — cached data + no onboarding + backend down
75
152
  it('returning user sees cached apps even when backend is unavailable', async () => {
76
153
  suppressConsole([/TRPC Error/, /Failed to fetch/])
@@ -152,6 +229,35 @@ describe('App Catalog Integration', () => {
152
229
  expect(subResources!.names).toContain('acct-staging')
153
230
  })
154
231
 
232
+ it('surfaces the parent app when searching for a sub-resource name (#38)', async () => {
233
+ const { ui } = await given(
234
+ magazine.custom(({ backendCfg }) => {
235
+ const app = backendCfg.withApp({
236
+ slug: 'aws-console',
237
+ displayName: 'AWS Console',
238
+ description: 'Cloud management console',
239
+ })
240
+ backendCfg.withSubResource({
241
+ appSlug: app.slug,
242
+ displayName: 'acct-payments-prod',
243
+ tier: 'prod',
244
+ })
245
+ backendCfg.withApp({
246
+ slug: 'unrelated',
247
+ displayName: 'Unrelated Tool',
248
+ description: 'Nothing to do with the query',
249
+ })
250
+ }),
251
+ )
252
+
253
+ // Query matches only a sub-resource name; the parent app must appear in the
254
+ // launcher search-morph results (cross-sub-resource search preserved).
255
+ await ui.catalog.search('acct-payments-prod')
256
+ const names = ui.catalog.getTableData().map((r) => r.name)
257
+ expect(names).toContain('AWS Console')
258
+ expect(names).not.toContain('Unrelated Tool')
259
+ })
260
+
155
261
  // Test 4: Malformed Response — HTML Instead of JSON
156
262
  it('shows error when backend returns HTML instead of JSON', async () => {
157
263
  suppressConsole([
@@ -53,30 +53,53 @@ describe('App deep-link routing', () => {
53
53
  expect(ui.catalog.isDetailPanelOpen()).toBe(false)
54
54
  })
55
55
 
56
- it('keeps the search text and focus after auto-navigating to a single match (#10)', async () => {
57
- // Typing a query that narrows the catalog to exactly one app auto-opens
58
- // that app's detail page. Before the fix the search value lived in
59
- // component-local state (not the URL), and the filters provider remounts
60
- // per route, so the input text (and focus) were wiped on navigation.
61
- // This starts from an EMPTY search (initialRoute '/') and types — the real
62
- // user flow — so it also guards the effect-ordering race where the child
63
- // auto-navigate effect runs before the provider's async state→URL sync.
64
- const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
56
+ it('a single search match stays in the launcher morph — no auto-jump to /app (#38)', async () => {
57
+ // Regression (#38): a search narrowing to exactly one app used to auto-open
58
+ // that app's detail route, which with the launcher yanked the user out
59
+ // of the search-morph into the old grid+panel (reported: typing "biom"
60
+ // jumped straight to an app-detail page). The morph now shows the single
61
+ // match as a selectable row; the user opts in (click /) to open it.
62
+ //
63
+ // Seed via sessionStorage (returning user who typed before this render):
64
+ // jsdom userEvent.type + useDeferredValue drops characters, orthogonal here.
65
+ const { ui, router } = await given(magazine.full(), {
66
+ initialRoute: '/',
67
+ seedSearch: 'jira',
68
+ })
65
69
 
66
- await ui.catalog.search('jira')
70
+ // Stays on the launcher home — NO auto-navigation to the detail route.
71
+ await waitFor(() => {
72
+ expect(ui.catalog.getTableData().length).toBeGreaterThan(0)
73
+ })
74
+ expect(router.state.location.pathname).toBe('/')
75
+ expect(ui.catalog.getTableData().map((r) => r.name)).toContain('Jira')
76
+ // Search text preserved (sessionStorage-backed), no ?q leak.
77
+ expect(ui.catalog.getSearchInput().value).toBe('jira')
78
+ expect((router.state.location.search as { q?: string }).q).toBeUndefined()
67
79
 
68
- // Auto-navigates to the single match, carrying the typed query into the URL
69
- // as `q` (the fix). The regression this guards: before the fix `q` was never
70
- // written, so on the per-route remount the input reset to '' and lost focus.
80
+ // Opting in (click) opens the detail.
81
+ await ui.catalog.openApp('Jira')
71
82
  await waitFor(() => {
72
83
  expect(router.state.location.pathname).toBe('/app/jira')
73
84
  })
85
+ })
74
86
 
75
- // The query is persisted in the URL and the input reflects it they stay in
76
- // sync across the navigation, so the typed text is never lost.
77
- const q = (router.state.location.search as { q?: string }).q
78
- expect(q).toBeTruthy()
79
- expect(ui.catalog.getSearchInput().value).toBe(q)
87
+ it('strips a legacy ?q= from the URL on the app detail route (#27)', async () => {
88
+ // Old shared links may still carry `?q=` (e.g. /app/jira?q=jira). `q` is
89
+ // still declared in the route search schema (so it validates), but the
90
+ // route's `stripSearchParams(['q'])` middleware rewrites the URL to a clean
91
+ // /app/jira on load. We no longer read `q` from the URL, so a bare deep-link
92
+ // doesn't repopulate the input from the query string.
93
+ const { ui, router } = await given(magazine.full(), {
94
+ initialRoute: '/app/jira?q=jira',
95
+ })
96
+
97
+ await waitFor(() => {
98
+ expect(ui.catalog.isDetailPanelOpen()).toBe(true)
99
+ })
100
+ expect(router.state.location.pathname).toBe('/app/jira')
101
+ expect(router.state.location.href).toBe('/app/jira')
102
+ expect((router.state.location.search as { q?: string }).q).toBeUndefined()
80
103
  })
81
104
 
82
105
  it('redirects /app/<alias> to the canonical /app/<slug> (replace, no back entry)', async () => {
@@ -102,17 +125,20 @@ describe('App deep-link routing', () => {
102
125
  expect(router.history.canGoBack()).toBe(false)
103
126
  })
104
127
 
105
- it('populates the search input from ?q= on the app detail route (#10 read path)', async () => {
106
- // The detail route must NOT strip `q` from the URL: landing on /app/<slug>
107
- // with a query must repopulate the search input. This is what was actually
108
- // broken the route had no validateSearch schema, so `q` was dropped.
109
- const { ui } = await given(magazine.full(), {
110
- initialRoute: '/app/jira?q=jira',
128
+ it('restores the search input from sessionStorage on the app detail route (#10 read path, #27)', async () => {
129
+ // The #10 guarantee - landing on /app/<slug> repopulates the search input -
130
+ // now sources the query from sessionStorage instead of `?q=` (#27). Seed the
131
+ // store as a returning user would have (typed a search, then navigated), and
132
+ // assert the detail route restores it with no `q` in the URL.
133
+ const { ui, router } = await given(magazine.full(), {
134
+ initialRoute: '/app/jira',
135
+ seedSearch: 'jira',
111
136
  })
112
137
 
113
138
  await waitFor(() => {
114
139
  expect(ui.catalog.isDetailPanelOpen()).toBe(true)
115
140
  })
116
141
  expect(ui.catalog.getSearchInput().value).toBe('jira')
142
+ expect((router.state.location.search as { q?: string }).q).toBeUndefined()
117
143
  })
118
144
  })
@@ -5,7 +5,7 @@ import { given } from './harness/given'
5
5
  import { magazine } from './mock-backend/magazines'
6
6
 
7
7
  // #25 regression: descriptions now carry markdown cross-reference links like
8
- // `[Example Portal](/app/example-portal)`. The detail panel renders them
8
+ // `[Signatera Portal](/app/signatera-portal)`. The detail panel renders them
9
9
  // as real links, but the COMPACT surfaces (grid list preview / table row) show
10
10
  // `description` as plain text, so the raw markdown syntax leaked to users.
11
11
  // In clamped previews we render the visible text only — no bracket/paren
@@ -13,10 +13,10 @@ import { magazine } from './mock-backend/magazines'
13
13
  describe('Cross-reference links do not leak raw markdown in the list view (#25)', () => {
14
14
  const withCrossRefApp = magazine.full(({ backendCfg }) => {
15
15
  backendCfg.withApp({
16
- slug: 'example-portals',
16
+ slug: 'natera-portals-ish',
17
17
  displayName: 'Portals Hub',
18
18
  description:
19
- 'See [Example Portal](/app/example-portal) and [Portal B](/app/portal-b).',
19
+ 'See [Signatera Portal](/app/signatera-portal) and [Prospera Portal](/app/prospera-portal).',
20
20
  })
21
21
  })
22
22
 
@@ -30,20 +30,20 @@ describe('Cross-reference links do not leak raw markdown in the list view (#25)'
30
30
  expect(row).toBeDefined()
31
31
 
32
32
  // The clamped preview shows the visible text…
33
- expect(row!.description).toContain('Example Portal')
34
- expect(row!.description).toContain('Portal B')
33
+ expect(row!.description).toContain('Signatera Portal')
34
+ expect(row!.description).toContain('Prospera Portal')
35
35
  // …and NEVER leaks the raw markdown link syntax.
36
36
  expect(row!.description).not.toContain('](/app/')
37
- expect(row!.description).not.toContain('[Example Portal]')
37
+ expect(row!.description).not.toContain('[Signatera Portal]')
38
38
  })
39
39
 
40
40
  it('does not render an interactive link inside the clamped list preview', async () => {
41
41
  await given(withCrossRefApp)
42
42
  await waitFor(() => {
43
- expect(screen.getByText(/See Example Portal and/)).toBeInTheDocument()
43
+ expect(screen.getByText(/See Signatera Portal and/)).toBeInTheDocument()
44
44
  })
45
45
  // A clamped preview must not carry a navigational link that could break the
46
46
  // clamp/layout — the interactive cross-links live in the detail panel.
47
- expect(screen.queryByRole('link', { name: 'Example Portal' })).toBeNull()
47
+ expect(screen.queryByRole('link', { name: 'Signatera Portal' })).toBeNull()
48
48
  })
49
49
  })
@@ -25,6 +25,7 @@ import { MockBackendVerifier } from './MockBackendVerifier'
25
25
  import { getGlobalError } from '../tools/ErrorTools'
26
26
  import type { GlobalError } from '../tools/ErrorTools'
27
27
  import { browserState } from '../tools/BrowserState'
28
+ import { SEARCH_STORAGE_KEY } from '~/modules/appCatalog/hooks/useSessionSyncedState'
28
29
  import { CatalogTools } from '../tools/CatalogTools'
29
30
  import { AppDetailTools } from '../tools/AppDetailTools'
30
31
  import { GalleryTools } from '../tools/GalleryTools'
@@ -60,7 +61,7 @@ export async function cleanupTestResources(): Promise<void> {
60
61
 
61
62
  export async function given(
62
63
  magazine: Magazine,
63
- opts: { initialRoute?: string } = {},
64
+ opts: { initialRoute?: string; seedSearch?: string } = {},
64
65
  ): Promise<GivenResult> {
65
66
  // Clean up any previous resources
66
67
  await cleanupTestResources()
@@ -92,6 +93,13 @@ export async function given(
92
93
  localStorage.setItem(key, value)
93
94
  }
94
95
 
96
+ // Seed the persisted catalog search (#27): simulates a returning user who had
97
+ // typed a query before navigating. Backs the same sessionStorage key the
98
+ // provider reads, so the search input repopulates without any `?q=` in the URL.
99
+ if (opts.seedSearch !== undefined) {
100
+ sessionStorage.setItem(SEARCH_STORAGE_KEY, opts.seedSearch)
101
+ }
102
+
95
103
  // 5. Build network catalog from backend service, then apply overrides
96
104
  const catalog = makeNetworkReplyWithCatalog(service)
97
105
  for (const override of networkCfg.overrides) {
@@ -14,8 +14,11 @@ describe('Deprecated app "View replacement" link (#12)', () => {
14
14
  // Deep-link straight onto the deprecated Old Tool with an active search of
15
15
  // "old tool" — which does NOT match "New Tool", so the replacement is
16
16
  // absent from the filtered list (the exact condition that broke the link).
17
+ // The search is seeded via sessionStorage (#27) — it no longer rides in the
18
+ // URL as `?q=`.
17
19
  const { ui, router } = await given(magazine.full(), {
18
- initialRoute: '/app/old-tool?q=old%20tool',
20
+ initialRoute: '/app/old-tool',
21
+ seedSearch: 'old tool',
19
22
  })
20
23
 
21
24
  await waitFor(() => {
@@ -41,7 +44,8 @@ describe('Deprecated app "View replacement" link (#12)', () => {
41
44
  // Hard navigation: /app/<slug> should render the app regardless of a
42
45
  // non-matching active search (browser-URL-like behavior).
43
46
  const { ui } = await given(magazine.full(), {
44
- initialRoute: '/app/jira?q=zzz-no-match-zzz',
47
+ initialRoute: '/app/jira',
48
+ seedSearch: 'zzz-no-match-zzz',
45
49
  })
46
50
 
47
51
  await waitFor(() => {
@@ -5,42 +5,47 @@ import '@testing-library/jest-dom/vitest'
5
5
  import { given } from './harness/given'
6
6
  import { magazine } from './mock-backend/magazines'
7
7
 
8
- // #11: when a search returns 0 non-deprecated results, fall back to including
9
- // deprecated apps show them, surface a notice, and auto-enable the
10
- // "Show Deprecated Apps" toggle. `magazine.full()` includes a deprecated app
11
- // "Old Tool" (slug old-tool) that is the only match for "old tool".
8
+ // #11: when a search returns 0 non-deprecated results, fall back to showing
9
+ // deprecated matches and surface a notice. In the launcher search-morph (#38)
10
+ // there is no separate "Show Deprecated Apps" toggle (a grid-era control)
11
+ // the morph searches the full set and only labels the deprecated-only case.
12
+ // `magazine.full()` includes a deprecated app "Old Tool" (slug old-tool) that
13
+ // is the only match for "old tool".
12
14
  describe('Search fallback to deprecated apps (#11)', () => {
13
- it('shows deprecated matches + notice + enables the toggle when 0 active results', async () => {
14
- // Seed the query via the URL (`q`) to avoid the jsdom useDeferredValue +
15
- // userEvent typing race. "old tool" matches ONLY the deprecated "Old Tool".
15
+ it('shows deprecated matches + notice when 0 active results', async () => {
16
+ // Seed the query via sessionStorage to avoid the jsdom useDeferredValue +
17
+ // userEvent typing race (#27 removed `?q=` from the URL). "old tool" matches
18
+ // ONLY the deprecated "Old Tool".
16
19
  const { ui } = await given(magazine.full(), {
17
- initialRoute: '/?q=old%20tool',
20
+ initialRoute: '/',
21
+ seedSearch: 'old tool',
18
22
  })
19
23
 
20
- // The fallback surfaces the deprecated match, shows the notice, and the
21
- // "Show Deprecated Apps" toggle is auto-enabled.
24
+ // The fallback surfaces the deprecated match and shows the notice.
22
25
  await waitFor(() => {
23
26
  expect(ui.catalog.hasDeprecatedFallbackNotice()).toBe(true)
24
27
  })
25
28
  expect(ui.catalog.getTableData().map((r) => r.name)).toContain('Old Tool')
26
- expect(ui.catalog.isShowDeprecatedChecked()).toBe(true)
27
29
  })
28
30
 
29
- it('does not force deprecated results when the query has an active match', async () => {
30
- const { ui } = await given(magazine.full(), { initialRoute: '/?q=Jira' })
31
+ it('does not show the fallback notice when the query has an active match', async () => {
32
+ const { ui } = await given(magazine.full(), {
33
+ initialRoute: '/',
34
+ seedSearch: 'Jira',
35
+ })
31
36
 
32
37
  await waitFor(() => {
33
38
  const names = ui.catalog.getTableData().map((r) => r.name)
34
39
  expect(names).toContain('Jira')
35
40
  })
36
- // Active match exists → no fallback, no notice, toggle stays off.
41
+ // Active match exists → no deprecated-fallback notice.
37
42
  expect(ui.catalog.hasDeprecatedFallbackNotice()).toBe(false)
38
- expect(ui.catalog.isShowDeprecatedChecked()).toBe(false)
39
43
  })
40
44
 
41
45
  it('keeps the normal empty state when nothing matches at all', async () => {
42
46
  const { ui } = await given(magazine.full(), {
43
- initialRoute: '/?q=zzz-nothing-matches-zzz',
47
+ initialRoute: '/',
48
+ seedSearch: 'zzz-nothing-matches-zzz',
44
49
  })
45
50
 
46
51
  await waitFor(() => {
@@ -19,6 +19,7 @@ afterEach(async () => {
19
19
  cleanup()
20
20
  await cleanupTestResources()
21
21
  localStorage.clear()
22
+ sessionStorage.clear()
22
23
  })
23
24
 
24
25
  // Mock window.matchMedia — required by embla-carousel in jsdom environment
@@ -0,0 +1,56 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { waitFor } from '@testing-library/react'
3
+ import '@testing-library/jest-dom/vitest'
4
+
5
+ import { given } from './harness/given'
6
+ import { magazine } from './mock-backend/magazines'
7
+
8
+ // The dev-only skew warning fires when resources loaded but 0 are top-level
9
+ // (fingerprint of a frontend/backend-core version skew or stale service worker).
10
+ describe('empty-catalog skew warning (dev-only)', () => {
11
+ let warnSpy: ReturnType<typeof vi.spyOn>
12
+ const origDev = import.meta.env.DEV
13
+
14
+ beforeEach(() => {
15
+ // Force DEV so the guarded warning path runs under the test runner.
16
+ ;(import.meta.env as { DEV: boolean }).DEV = true
17
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
18
+ })
19
+ afterEach(() => {
20
+ warnSpy.mockRestore()
21
+ ;(import.meta.env as { DEV: boolean }).DEV = origDev
22
+ })
23
+
24
+ it('warns when resources load but none are top-level (skew fingerprint)', async () => {
25
+ // Skew fixture: one resource, and it is a CHILD (parentSlug set) → rootResources === 0.
26
+ const skew = magazine.custom(({ backendCfg }) => {
27
+ backendCfg.withApp({
28
+ slug: 'child-only',
29
+ displayName: 'Child Only',
30
+ parentSlug: 'ghost-parent',
31
+ })
32
+ })
33
+ const { ui } = await given(skew)
34
+ await waitFor(() => {
35
+ expect(
36
+ warnSpy.mock.calls.some((c: unknown[]) =>
37
+ String(c[0]).includes('0 are top-level'),
38
+ ),
39
+ ).toBe(true)
40
+ })
41
+ // sanity: catalog rendered the empty state, not a crash
42
+ expect(ui.catalog).toBeDefined()
43
+ })
44
+
45
+ it('does NOT warn for a normal populated catalog', async () => {
46
+ const { ui } = await given(magazine.full())
47
+ await waitFor(() => {
48
+ expect(ui.catalog.getTableData().length).toBeGreaterThan(0)
49
+ })
50
+ expect(
51
+ warnSpy.mock.calls.some((c: unknown[]) =>
52
+ String(c[0]).includes('0 are top-level'),
53
+ ),
54
+ ).toBe(false)
55
+ })
56
+ })
@@ -113,6 +113,19 @@ export class AppDetailTools {
113
113
  return { total, visible, names }
114
114
  }
115
115
 
116
+ /**
117
+ * Read the "How to get access" section text from the detail panel.
118
+ * Returns null if the section is not present.
119
+ */
120
+ getAccessText(): string | null {
121
+ const panel = this.getPanel()
122
+ const heading = this.findHeading(panel, 'How to get access')
123
+ if (!heading) return null
124
+ // The section is the heading plus its following content wrapper.
125
+ const container = heading.parentElement ?? heading
126
+ return container.textContent.trim()
127
+ }
128
+
116
129
  screenshots = {
117
130
  /**
118
131
  * Click the screenshot preview to open the gallery modal.
@@ -11,24 +11,33 @@ export class CatalogTools {
11
11
 
12
12
  /**
13
13
  * Click an app row by display name.
14
+ * Works against both the table/grid view and the launcher home (#38), which
15
+ * renders app rows as buttons rather than a <table>.
14
16
  * Throws with list of visible apps if name not found.
15
17
  */
16
18
  async openApp(name: string): Promise<void> {
17
19
  const table = this.getCatalogTable()
18
- if (!table) throw new Error('No catalog table found')
19
- const rows = within(table).getAllByRole('row')
20
-
21
- for (const row of rows) {
22
- const nameEl = row.querySelector('.font-medium')
23
- if (nameEl?.textContent.trim() === name) {
24
- await this.user.click(row)
20
+ if (table) {
21
+ const rows = within(table).getAllByRole('row')
22
+ for (const row of rows) {
23
+ const nameEl = row.querySelector('.font-medium')
24
+ if (nameEl?.textContent.trim() === name) {
25
+ await this.user.click(row)
26
+ return
27
+ }
28
+ }
29
+ } else {
30
+ // Launcher home: rows are buttons titled "View <name>".
31
+ const btn = screen.queryByTitle(`View ${name}`)
32
+ if (btn) {
33
+ await this.user.click(btn)
25
34
  return
26
35
  }
27
36
  }
28
37
 
29
38
  const visibleNames = this.getTableData().map((r) => r.name)
30
39
  throw new Error(
31
- `App "${name}" not found in table. Visible apps: [${visibleNames.join(', ')}]`,
40
+ `App "${name}" not found. Visible apps: [${visibleNames.join(', ')}]`,
32
41
  )
33
42
  }
34
43
 
@@ -63,7 +72,10 @@ export class CatalogTools {
63
72
  `Cannot read table — global error on page: ${bodyText.slice(0, 500)}`,
64
73
  )
65
74
  }
66
- throw new Error('No table found on page')
75
+ // Launcher home (#38): no <table>; read the app row buttons instead.
76
+ const launcherRows = this.getLauncherRows()
77
+ if (launcherRows.length > 0) return launcherRows
78
+ throw new Error('No table or launcher rows found on page')
67
79
  }
68
80
 
69
81
  const rows = within(table).getAllByRole('row')
@@ -86,6 +98,16 @@ export class CatalogTools {
86
98
  return result
87
99
  }
88
100
 
101
+ /**
102
+ * The secondary "open in new tab" launch link for a row, by app name.
103
+ * Returns null if the row has no launch link.
104
+ */
105
+ getLaunchLink(name: string): HTMLAnchorElement | null {
106
+ return screen.queryByLabelText<HTMLAnchorElement>(
107
+ `Open ${name} in a new tab`,
108
+ )
109
+ }
110
+
89
111
  /**
90
112
  * Whether the right detail panel is currently visible.
91
113
  */
@@ -107,9 +129,12 @@ export class CatalogTools {
107
129
  return !!screen.queryByText(/showing deprecated matches/i)
108
130
  }
109
131
 
110
- /** Whether the "No apps found" empty state is visible. */
132
+ /** Whether the "No apps found" empty state or search-morph "No results for" is visible. */
111
133
  isEmptyStateVisible(): boolean {
112
- return !!screen.queryByText(/No apps found/i)
134
+ return (
135
+ !!screen.queryByText(/No apps found/i) ||
136
+ !!screen.queryByText(/No results for/i)
137
+ )
113
138
  }
114
139
 
115
140
  /**
@@ -119,6 +144,30 @@ export class CatalogTools {
119
144
  return !!screen.queryByText('Welcome to App Catalog')
120
145
  }
121
146
 
147
+ /**
148
+ * Read app rows from the launcher home (#38): buttons titled "View <name>".
149
+ * Name is the first `.font-semibold`/`.font-bold` span; description the
150
+ * `.text-muted-foreground` span. Deduped by name (an app can appear both in
151
+ * "Your apps" and "Browse all").
152
+ */
153
+ private getLauncherRows(): TableRow[] {
154
+ const buttons = Array.from(
155
+ document.querySelectorAll<HTMLElement>('button[title^="View "]'),
156
+ )
157
+ const seen = new Set<string>()
158
+ const result: TableRow[] = []
159
+ for (const btn of buttons) {
160
+ const name = (btn.getAttribute('title') ?? '')
161
+ .replace(/^View\s+/, '')
162
+ .trim()
163
+ if (!name || seen.has(name)) continue
164
+ seen.add(name)
165
+ const descEl = btn.querySelector('.text-muted-foreground')
166
+ result.push({ name, description: descEl?.textContent.trim() ?? '' })
167
+ }
168
+ return result
169
+ }
170
+
122
171
  /**
123
172
  * Get the main catalog table (first table on page, skipping sub-resource tables in detail panel).
124
173
  */
@@ -3,14 +3,14 @@ import { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainT
3
3
 
4
4
  // #25 regression: markdown-bearing text fields (descriptions) are rendered raw
5
5
  // in compact/clamped surfaces (grid list preview, table row, filter combobox,
6
- // sub-resources). Link syntax like `[Example Portal](/app/example-portal)`
6
+ // sub-resources). Link syntax like `[Signatera Portal](/app/signatera-portal)`
7
7
  // leaked as literal text. In clamped previews we strip markdown to its visible
8
8
  // text so nothing leaks and `line-clamp` / search highlighting keep working.
9
9
  describe('markdownToPlainText', () => {
10
10
  it('reduces an internal link to just its text', () => {
11
11
  expect(
12
- markdownToPlainText('See [Example Portal](/app/example-portal) too'),
13
- ).toBe('See Example Portal too')
12
+ markdownToPlainText('See [Signatera Portal](/app/signatera-portal) too'),
13
+ ).toBe('See Signatera Portal too')
14
14
  })
15
15
 
16
16
  it('reduces an external link to just its text', () => {
@@ -22,9 +22,9 @@ describe('markdownToPlainText', () => {
22
22
  it('handles multiple links in one string', () => {
23
23
  expect(
24
24
  markdownToPlainText(
25
- '[Example Portal](/app/example-portal) and [Portal B](/app/portal-b)',
25
+ '[Signatera Portal](/app/signatera-portal) and [Prospera Portal](/app/prospera-portal)',
26
26
  ),
27
- ).toBe('Example Portal and Portal B')
27
+ ).toBe('Signatera Portal and Prospera Portal')
28
28
  })
29
29
 
30
30
  it('strips image syntax to its alt text', () => {