@igstack/app-catalog-test-kit 0.18.6

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 (92) hide show
  1. package/README.md +51 -0
  2. package/dist/esm/harness/MockBackendVerifier.d.ts +8 -0
  3. package/dist/esm/harness/MockBackendVerifier.js +15 -0
  4. package/dist/esm/harness/MockBackendVerifier.js.map +1 -0
  5. package/dist/esm/harness/given.d.ts +23 -0
  6. package/dist/esm/harness/given.js +144 -0
  7. package/dist/esm/harness/given.js.map +1 -0
  8. package/dist/esm/index.d.ts +38 -0
  9. package/dist/esm/index.js +45 -0
  10. package/dist/esm/index.js.map +1 -0
  11. package/dist/esm/mock-backend/BrowserStateCfg.d.ts +18 -0
  12. package/dist/esm/mock-backend/BrowserStateCfg.js +33 -0
  13. package/dist/esm/mock-backend/BrowserStateCfg.js.map +1 -0
  14. package/dist/esm/mock-backend/MockBackendConfigurer.d.ts +17 -0
  15. package/dist/esm/mock-backend/MockBackendConfigurer.js +80 -0
  16. package/dist/esm/mock-backend/MockBackendConfigurer.js.map +1 -0
  17. package/dist/esm/mock-backend/MockDb.d.ts +18 -0
  18. package/dist/esm/mock-backend/MockDb.js +56 -0
  19. package/dist/esm/mock-backend/MockDb.js.map +1 -0
  20. package/dist/esm/mock-backend/MockService.d.ts +10 -0
  21. package/dist/esm/mock-backend/MockService.js +16 -0
  22. package/dist/esm/mock-backend/MockService.js.map +1 -0
  23. package/dist/esm/mock-backend/MockUserContext.d.ts +11 -0
  24. package/dist/esm/mock-backend/MockUserContext.js +38 -0
  25. package/dist/esm/mock-backend/MockUserContext.js.map +1 -0
  26. package/dist/esm/mock-backend/magazines.d.ts +22 -0
  27. package/dist/esm/mock-backend/magazines.js +186 -0
  28. package/dist/esm/mock-backend/magazines.js.map +1 -0
  29. package/dist/esm/mock-network/NetworkCatalog.d.ts +12 -0
  30. package/dist/esm/mock-network/NetworkCatalog.js +24 -0
  31. package/dist/esm/mock-network/NetworkCatalog.js.map +1 -0
  32. package/dist/esm/mock-network/NetworkConfigurerCfg.d.ts +13 -0
  33. package/dist/esm/mock-network/NetworkConfigurerCfg.js +16 -0
  34. package/dist/esm/mock-network/NetworkConfigurerCfg.js.map +1 -0
  35. package/dist/esm/mock-network/SharedNetwork.d.ts +13 -0
  36. package/dist/esm/mock-network/SharedNetwork.js +149 -0
  37. package/dist/esm/mock-network/SharedNetwork.js.map +1 -0
  38. package/dist/esm/mock-network/errorFactories.d.ts +11 -0
  39. package/dist/esm/mock-network/errorFactories.js +22 -0
  40. package/dist/esm/mock-network/errorFactories.js.map +1 -0
  41. package/dist/esm/mock-network/makeNetworkReplyWithCatalog.d.ts +3 -0
  42. package/dist/esm/mock-network/makeNetworkReplyWithCatalog.js +17 -0
  43. package/dist/esm/mock-network/makeNetworkReplyWithCatalog.js.map +1 -0
  44. package/dist/esm/setup/polyfillLocalStorage.d.ts +0 -0
  45. package/dist/esm/setup/polyfillLocalStorage.js +25 -0
  46. package/dist/esm/setup/polyfillLocalStorage.js.map +1 -0
  47. package/dist/esm/setup/svgMock.d.ts +1 -0
  48. package/dist/esm/setup/testSetup.d.ts +0 -0
  49. package/dist/esm/setup/testSetup.js +58 -0
  50. package/dist/esm/setup/testSetup.js.map +1 -0
  51. package/dist/esm/tools/AppDetailTools.d.ts +63 -0
  52. package/dist/esm/tools/AppDetailTools.js +180 -0
  53. package/dist/esm/tools/AppDetailTools.js.map +1 -0
  54. package/dist/esm/tools/BrowserState.d.ts +25 -0
  55. package/dist/esm/tools/BrowserState.js +30 -0
  56. package/dist/esm/tools/BrowserState.js.map +1 -0
  57. package/dist/esm/tools/CatalogTools.d.ts +67 -0
  58. package/dist/esm/tools/CatalogTools.js +202 -0
  59. package/dist/esm/tools/CatalogTools.js.map +1 -0
  60. package/dist/esm/tools/ErrorTools.d.ts +15 -0
  61. package/dist/esm/tools/ErrorTools.js +34 -0
  62. package/dist/esm/tools/ErrorTools.js.map +1 -0
  63. package/dist/esm/tools/GalleryTools.d.ts +6 -0
  64. package/dist/esm/tools/GalleryTools.js +22 -0
  65. package/dist/esm/tools/GalleryTools.js.map +1 -0
  66. package/dist/esm/tools/suppressConsole.d.ts +19 -0
  67. package/dist/esm/tools/suppressConsole.js +44 -0
  68. package/dist/esm/tools/suppressConsole.js.map +1 -0
  69. package/package.json +87 -0
  70. package/src/harness/MockBackendVerifier.ts +14 -0
  71. package/src/harness/given.tsx +212 -0
  72. package/src/index.ts +44 -0
  73. package/src/mock-backend/BrowserStateCfg.ts +35 -0
  74. package/src/mock-backend/MockBackendConfigurer.ts +97 -0
  75. package/src/mock-backend/MockDb.ts +66 -0
  76. package/src/mock-backend/MockService.ts +18 -0
  77. package/src/mock-backend/MockUserContext.ts +42 -0
  78. package/src/mock-backend/magazines.ts +245 -0
  79. package/src/mock-network/NetworkCatalog.ts +30 -0
  80. package/src/mock-network/NetworkConfigurerCfg.ts +24 -0
  81. package/src/mock-network/SharedNetwork.ts +100 -0
  82. package/src/mock-network/errorFactories.ts +28 -0
  83. package/src/mock-network/makeNetworkReplyWithCatalog.ts +19 -0
  84. package/src/setup/polyfillLocalStorage.ts +34 -0
  85. package/src/setup/svgMock.tsx +3 -0
  86. package/src/setup/testSetup.ts +58 -0
  87. package/src/tools/AppDetailTools.ts +240 -0
  88. package/src/tools/BrowserState.ts +41 -0
  89. package/src/tools/CatalogTools.ts +254 -0
  90. package/src/tools/ErrorTools.ts +49 -0
  91. package/src/tools/GalleryTools.ts +26 -0
  92. package/src/tools/suppressConsole.ts +66 -0
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Polyfill localStorage for jsdom environments where it's missing or incomplete.
3
+ *
4
+ * jsdom only provides localStorage when initialized with a valid URL (not about:blank).
5
+ * Even with environmentOptions.jsdom.url set, some CI environments (GitHub Actions)
6
+ * still don't provide a fully functional localStorage. This polyfill ensures it works.
7
+ */
8
+ if (
9
+ typeof globalThis.localStorage === 'undefined' ||
10
+ typeof globalThis.localStorage.clear !== 'function'
11
+ ) {
12
+ const store = new Map<string, string>()
13
+ const storage: Storage = {
14
+ getItem: (key: string) => store.get(key) ?? null,
15
+ setItem: (key: string, value: string) => {
16
+ store.set(key, String(value))
17
+ },
18
+ removeItem: (key: string) => {
19
+ store.delete(key)
20
+ },
21
+ clear: () => {
22
+ store.clear()
23
+ },
24
+ get length() {
25
+ return store.size
26
+ },
27
+ key: (index: number) => [...store.keys()][index] ?? null,
28
+ }
29
+ Object.defineProperty(globalThis, 'localStorage', {
30
+ value: storage,
31
+ writable: true,
32
+ configurable: true,
33
+ })
34
+ }
@@ -0,0 +1,3 @@
1
+ export default function SvgMock(props: React.SVGProps<SVGSVGElement>) {
2
+ return <svg data-testid="svg-mock" {...props} />
3
+ }
@@ -0,0 +1,58 @@
1
+ import 'fake-indexeddb/auto'
2
+ import './polyfillLocalStorage'
3
+ import { afterEach, vi } from 'vitest'
4
+ import { cleanup } from '@testing-library/react'
5
+ import { cleanupTestResources } from '../harness/given'
6
+ import React from 'react'
7
+
8
+ // Mock SVG React component imports (vite-plugin-svgr ?react suffix)
9
+ vi.mock('~/assets/app-catalog.svg?react', () => ({
10
+ default: (props: React.SVGProps<SVGSVGElement>) =>
11
+ React.createElement('svg', { 'data-testid': 'svg-mock', ...props }),
12
+ }))
13
+
14
+ // Mock scrollIntoView — not implemented in jsdom
15
+ Element.prototype.scrollIntoView = vi.fn()
16
+
17
+ // Clean up after each test
18
+ afterEach(async () => {
19
+ cleanup()
20
+ await cleanupTestResources()
21
+ localStorage.clear()
22
+ sessionStorage.clear()
23
+ })
24
+
25
+ // Mock window.matchMedia — required by embla-carousel in jsdom environment
26
+ Object.defineProperty(window, 'matchMedia', {
27
+ writable: true,
28
+ value: (query: string) => ({
29
+ matches: false,
30
+ media: query,
31
+ onchange: null,
32
+ addListener: () => {},
33
+ removeListener: () => {},
34
+ addEventListener: () => {},
35
+ removeEventListener: () => {},
36
+ dispatchEvent: () => false,
37
+ }),
38
+ })
39
+
40
+ // Mock IntersectionObserver — required by embla-carousel SlidesInView in jsdom
41
+ globalThis.IntersectionObserver = class IntersectionObserver {
42
+ observe() {}
43
+ unobserve() {}
44
+ disconnect() {}
45
+ takeRecords() {
46
+ return []
47
+ }
48
+ readonly root = null
49
+ readonly rootMargin = ''
50
+ readonly thresholds = []
51
+ } as unknown as typeof IntersectionObserver
52
+
53
+ // Mock ResizeObserver — required by embla-carousel ResizeHandler in jsdom
54
+ globalThis.ResizeObserver = class ResizeObserver {
55
+ observe() {}
56
+ unobserve() {}
57
+ disconnect() {}
58
+ } as unknown as typeof ResizeObserver
@@ -0,0 +1,240 @@
1
+ import { screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+
4
+ export interface AppVisibleData {
5
+ title: string
6
+ description: string | null
7
+ url: string | null
8
+ tags: string[]
9
+ screenshots: { count: number }
10
+ deprecation: { type: string; comment: string } | null
11
+ }
12
+
13
+ export class AppDetailTools {
14
+ private user = userEvent.setup()
15
+
16
+ /**
17
+ * Scrape all visible data from the app detail panel into a structured object.
18
+ */
19
+ getVisibleData(): AppVisibleData {
20
+ const panel = this.getPanel()
21
+
22
+ // Title: the large text in the header area
23
+ const titleEl = panel.querySelector('.text-2xl')
24
+ const title = titleEl?.textContent.trim() ?? ''
25
+
26
+ // Description
27
+ const descHeading = this.findHeading(panel, 'Description')
28
+ const description = descHeading
29
+ ? this.getNextSiblingText(descHeading)
30
+ : null
31
+
32
+ // URL
33
+ const urlLink = panel.querySelector('a[href][target="_blank"]')
34
+ const url = urlLink?.getAttribute('href') ?? null
35
+
36
+ // Tags
37
+ const tagsHeading = this.findHeading(panel, 'Tags')
38
+ const tags: string[] = []
39
+ if (tagsHeading) {
40
+ const container = tagsHeading.nextElementSibling
41
+ if (container) {
42
+ container
43
+ .querySelectorAll('[class*="badge"], [data-slot="badge"]')
44
+ .forEach((badge) => {
45
+ const text = badge.textContent.trim()
46
+ if (text) tags.push(text)
47
+ })
48
+ }
49
+ }
50
+
51
+ // Screenshots
52
+ const screenshotHeading = this.findHeading(panel, 'Screenshots')
53
+ let screenshotCount = 0
54
+ if (screenshotHeading) {
55
+ const match = screenshotHeading.textContent.match(/\((\d+)\)/)
56
+ screenshotCount = match?.[1] ? parseInt(match[1], 10) : 0
57
+ }
58
+
59
+ // Deprecation
60
+ let deprecation: AppVisibleData['deprecation'] = null
61
+ const deprecationEl = panel.querySelector(
62
+ '[class*="border-destructive"], [class*="border-yellow"]',
63
+ )
64
+ if (deprecationEl) {
65
+ const typeEl = deprecationEl.querySelector('h3')
66
+ const commentEl = deprecationEl.querySelector('p')
67
+ deprecation = {
68
+ type: typeEl?.textContent.toLowerCase().includes('discouraged')
69
+ ? 'discouraged'
70
+ : 'deprecated',
71
+ comment: commentEl?.textContent.trim() ?? '',
72
+ }
73
+ }
74
+
75
+ return {
76
+ title,
77
+ description,
78
+ url,
79
+ tags,
80
+ screenshots: { count: screenshotCount },
81
+ deprecation,
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Get the sub-resources section data from the detail panel.
87
+ * Returns null if no sub-resources section is visible.
88
+ */
89
+ getSubResources(): {
90
+ total: number
91
+ visible: number
92
+ names: string[]
93
+ } | null {
94
+ const panel = this.getPanel()
95
+ const heading = Array.from(panel.querySelectorAll('div')).find((el) =>
96
+ el.textContent.match(/Sub-Resources \(\d+ of \d+\)/),
97
+ )
98
+ if (!heading) return null
99
+
100
+ const match = heading.textContent.match(/Sub-Resources \((\d+) of (\d+)\)/)
101
+ const visible = match?.[1] ? parseInt(match[1], 10) : 0
102
+ const total = match?.[2] ? parseInt(match[2], 10) : 0
103
+
104
+ const names: string[] = []
105
+ const rows = panel.querySelectorAll('table tbody tr')
106
+ rows.forEach((row) => {
107
+ const nameCell = row.querySelector('td .font-medium')
108
+ if (nameCell?.textContent) {
109
+ names.push(nameCell.textContent.trim())
110
+ }
111
+ })
112
+
113
+ return { total, visible, names }
114
+ }
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
+
129
+ screenshots = {
130
+ /**
131
+ * Click the screenshot preview to open the gallery modal.
132
+ */
133
+ open: async (): Promise<void> => {
134
+ const panel = this.getPanel()
135
+ const screenshotArea = panel.querySelector('.cursor-pointer')
136
+ if (!screenshotArea) {
137
+ throw new Error('No clickable screenshot found in detail panel')
138
+ }
139
+ await this.user.click(screenshotArea)
140
+ },
141
+ }
142
+
143
+ /** Click the "View replacement: <App>" link in a deprecated app's panel. */
144
+ async clickViewReplacement(): Promise<void> {
145
+ const btn = screen.getByRole('button', { name: /View replacement:/i })
146
+ await this.user.click(btn)
147
+ }
148
+
149
+ /** The title shown in the currently open detail panel (empty if none). */
150
+ getOpenTitle(): string {
151
+ const panel = this.getPanel()
152
+ return panel.querySelector('.text-2xl')?.textContent.trim() ?? ''
153
+ }
154
+
155
+ /**
156
+ * The prominent primary "Open" button in the app detail card.
157
+ * Returns the anchor element when the app has an appUrl, null otherwise.
158
+ */
159
+ getOpenButton(): HTMLAnchorElement | null {
160
+ const panel = this.getPanel()
161
+ return (
162
+ panel.querySelector<HTMLAnchorElement>('[aria-label^="Open "]') ?? null
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Get sub-resource detail panel data when a sub-resource is selected.
168
+ * Returns null if the sub-resource detail panel is not shown.
169
+ */
170
+ getSubResourceDetail(): {
171
+ subResourceName: string
172
+ hasStep1: boolean
173
+ hasStep2: boolean
174
+ backButtonLabel: string | null
175
+ } | null {
176
+ const panel = document.querySelector<HTMLElement>(
177
+ '[aria-label^="Back to "]',
178
+ )
179
+ if (!panel) return null
180
+
181
+ const backButtonEl = document.querySelector<HTMLElement>(
182
+ '[aria-label^="Back to "]',
183
+ )
184
+ const backButtonLabel = backButtonEl?.getAttribute('aria-label') ?? null
185
+
186
+ // Sub-resource name: h2 in the detail panel area
187
+ const h2 = document.querySelector('h2.text-xl')
188
+ const subResourceName = (h2?.textContent ?? '').trim()
189
+
190
+ // Step badges: circles with "1" and "2" text
191
+ const stepBadges = Array.from(
192
+ document.querySelectorAll<HTMLElement>('span.rounded-full'),
193
+ )
194
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
195
+ const getText = (el: HTMLElement) => (el.textContent ?? '').trim()
196
+ const hasStep1 = stepBadges.some((el) => getText(el) === '1')
197
+ const hasStep2 = stepBadges.some((el) => getText(el) === '2')
198
+
199
+ return { subResourceName, hasStep1, hasStep2, backButtonLabel }
200
+ }
201
+
202
+ async clickBackToParent(): Promise<void> {
203
+ const backButton = document.querySelector<HTMLButtonElement>(
204
+ '[aria-label^="Back to "]',
205
+ )
206
+ if (!backButton) {
207
+ throw new Error('Back-to-parent button not found in detail panel')
208
+ }
209
+ await this.user.click(backButton)
210
+ }
211
+
212
+ private getPanel(): HTMLElement {
213
+ const closeButton = screen.queryByLabelText('Close details panel')
214
+ if (!closeButton) {
215
+ throw new Error('App detail panel is not open')
216
+ }
217
+ // The panel is the closest scrollable container
218
+ const panel = closeButton.closest('[class*="overflow-y-auto"]')
219
+ if (!panel) {
220
+ throw new Error('Could not find detail panel container')
221
+ }
222
+ return panel as HTMLElement
223
+ }
224
+
225
+ private findHeading(
226
+ container: HTMLElement,
227
+ text: string,
228
+ ): HTMLElement | null {
229
+ const headings = container.querySelectorAll('h3')
230
+ for (const h of headings) {
231
+ if (h.textContent.includes(text)) return h as HTMLElement
232
+ }
233
+ return null
234
+ }
235
+
236
+ private getNextSiblingText(heading: HTMLElement): string | null {
237
+ const sibling = heading.nextElementSibling
238
+ return sibling?.textContent.trim() ?? null
239
+ }
240
+ }
@@ -0,0 +1,41 @@
1
+ import type { AppCatalogData } from '@igstack/app-catalog-backend-core'
2
+ import {
3
+ AcDb,
4
+ dbCacheDbKeys,
5
+ } from '@igstack/app-catalog-frontend-core/internal'
6
+
7
+ /**
8
+ * Pre-populate the browser's local state to simulate a returning user.
9
+ * Call before `given()` to set up IndexedDB cache and localStorage flags.
10
+ *
11
+ * Usage:
12
+ * await browserState.seedCatalogCache(data)
13
+ * browserState.dismissOnboarding()
14
+ */
15
+ export const browserState = {
16
+ /**
17
+ * Seed the IndexedDB cache with app catalog data,
18
+ * as if the user has previously loaded the app.
19
+ */
20
+ async seedCatalogCache(data: AppCatalogData): Promise<void> {
21
+ const db = new AcDb()
22
+ await db.appCatalog.put(data, dbCacheDbKeys.AppCatalog)
23
+ db.close()
24
+ },
25
+
26
+ /**
27
+ * Mark the onboarding card as dismissed in localStorage,
28
+ * as if the user has already seen and closed it.
29
+ */
30
+ dismissOnboarding(): void {
31
+ localStorage.setItem('app-catalog-onboarding-dismissed', 'true')
32
+ },
33
+
34
+ /**
35
+ * Simulate a returning user: onboarding dismissed + catalog data cached.
36
+ */
37
+ async returningUser(data: AppCatalogData): Promise<void> {
38
+ this.dismissOnboarding()
39
+ await this.seedCatalogCache(data)
40
+ },
41
+ }
@@ -0,0 +1,254 @@
1
+ import { fireEvent, screen, within } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+
4
+ export interface TableRow {
5
+ name: string
6
+ description: string
7
+ }
8
+
9
+ export class CatalogTools {
10
+ private user = userEvent.setup()
11
+
12
+ /**
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>.
16
+ * Throws with list of visible apps if name not found.
17
+ */
18
+ async openApp(name: string): Promise<void> {
19
+ const table = this.getCatalogTable()
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)
34
+ return
35
+ }
36
+ }
37
+
38
+ const visibleNames = this.getTableData().map((r) => r.name)
39
+ throw new Error(
40
+ `App "${name}" not found. Visible apps: [${visibleNames.join(', ')}]`,
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Type into the search input.
46
+ */
47
+ async search(text: string): Promise<void> {
48
+ const input = screen.getByLabelText('Search apps')
49
+ fireEvent.change(input, { target: { value: text } })
50
+ }
51
+
52
+ /** The catalog search input. */
53
+ getSearchInput(): HTMLInputElement {
54
+ return screen.getByLabelText<HTMLInputElement>('Search apps')
55
+ }
56
+
57
+ /**
58
+ * Parse the catalog table into structured data.
59
+ * Skips group header rows (colspan rows).
60
+ */
61
+ getTableData(): TableRow[] {
62
+ const table = this.getCatalogTable()
63
+ if (!table) {
64
+ // Check if there's a global error — throw with details for debugging
65
+ const bodyText = document.body.textContent
66
+ if (
67
+ bodyText.includes('Something went wrong') ||
68
+ bodyText.includes('Ooops')
69
+ ) {
70
+ throw new Error(
71
+ `Cannot read table — global error on page: ${bodyText.slice(0, 500)}`,
72
+ )
73
+ }
74
+ // Launcher home (#38): no <table>; read the app row buttons instead.
75
+ const launcherRows = this.getLauncherRows()
76
+ if (launcherRows.length > 0) return launcherRows
77
+ throw new Error('No table or launcher rows found on page')
78
+ }
79
+
80
+ const rows = within(table).getAllByRole('row')
81
+ const result: TableRow[] = []
82
+
83
+ for (const row of rows) {
84
+ const cells = within(row).queryAllByRole('cell')
85
+ // Skip header rows and group header rows (single cell with colspan)
86
+ if (cells.length < 2) continue
87
+
88
+ const nameEl = cells[0]?.querySelector('.font-medium')
89
+ if (!nameEl) continue
90
+
91
+ result.push({
92
+ name: nameEl.textContent.trim(),
93
+ description: cells[1]?.textContent.trim() ?? '',
94
+ })
95
+ }
96
+
97
+ return result
98
+ }
99
+
100
+ /**
101
+ * The secondary "open in new tab" launch link for a row, by app name.
102
+ * Returns null if the row has no launch link.
103
+ */
104
+ getLaunchLink(name: string): HTMLAnchorElement | null {
105
+ // The launch link's aria-label starts with "Open <name> in a new tab" and
106
+ // may append the destination URL, e.g. "… in a new tab (foo.example.com)".
107
+ return screen.queryByLabelText<HTMLAnchorElement>(
108
+ new RegExp(`^Open ${name} in a new tab`),
109
+ )
110
+ }
111
+
112
+ /**
113
+ * Whether the right detail panel is currently visible.
114
+ */
115
+ isDetailPanelOpen(): boolean {
116
+ return !!screen.queryByLabelText('Close details panel')
117
+ }
118
+
119
+ /** Whether the "Show Deprecated Apps" toggle is currently checked. */
120
+ isShowDeprecatedChecked(): boolean {
121
+ const cb = screen.getByRole('checkbox', { name: /Show Deprecated Apps/i })
122
+ return (
123
+ cb.getAttribute('aria-checked') === 'true' ||
124
+ cb.getAttribute('data-state') === 'checked'
125
+ )
126
+ }
127
+
128
+ /** Whether the "showing deprecated matches" fallback notice is visible. */
129
+ hasDeprecatedFallbackNotice(): boolean {
130
+ return !!screen.queryByText(/showing deprecated matches/i)
131
+ }
132
+
133
+ /** Whether the "No apps found" empty state or search-morph "No results for" is visible. */
134
+ isEmptyStateVisible(): boolean {
135
+ return (
136
+ !!screen.queryByText(/No apps found/i) ||
137
+ !!screen.queryByText(/No results for/i)
138
+ )
139
+ }
140
+
141
+ /**
142
+ * Whether the onboarding/welcome card is visible.
143
+ */
144
+ isOnboardingVisible(): boolean {
145
+ return !!screen.queryByText('Welcome to App Catalog')
146
+ }
147
+
148
+ /**
149
+ * Read app rows from the launcher home (#38): buttons titled "View <name>".
150
+ * Name is the first `.font-semibold`/`.font-bold` span; description the
151
+ * `.text-muted-foreground` span. Deduped by name (an app can appear both in
152
+ * "Your apps" and "Browse all").
153
+ */
154
+ private getLauncherRows(): TableRow[] {
155
+ const buttons = Array.from(
156
+ document.querySelectorAll<HTMLElement>('button[title^="View "]'),
157
+ )
158
+ const seen = new Set<string>()
159
+ const result: TableRow[] = []
160
+ for (const btn of buttons) {
161
+ const name = (btn.getAttribute('title') ?? '')
162
+ .replace(/^View\s+/, '')
163
+ .trim()
164
+ if (!name || seen.has(name)) continue
165
+ seen.add(name)
166
+ const descEl = btn.querySelector('.text-muted-foreground')
167
+ result.push({ name, description: descEl?.textContent.trim() ?? '' })
168
+ }
169
+ return result
170
+ }
171
+
172
+ /**
173
+ * Get expandable subresource rows shown under parents in search results.
174
+ * Returns null if no sub-rows are visible.
175
+ */
176
+ getSubResourceRows(): {
177
+ visible: number
178
+ total: number
179
+ hasExpandRow: boolean
180
+ names: string[]
181
+ } | null {
182
+ const subButtons = Array.from(
183
+ document.querySelectorAll<HTMLButtonElement>(
184
+ 'button[class*="border-l-2"]',
185
+ ),
186
+ )
187
+ if (subButtons.length === 0) return null
188
+
189
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
190
+ const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()
191
+ const isExpandRow = (el: HTMLButtonElement) => getText(el).startsWith('...')
192
+
193
+ const expandButton = subButtons.find(isExpandRow)
194
+ const hasExpandRow = !!expandButton
195
+
196
+ let hiddenCount = 0
197
+ if (expandButton) {
198
+ const match = /\d+/.exec(getText(expandButton))
199
+ hiddenCount = match?.[0] ? parseInt(match[0], 10) : 0
200
+ }
201
+
202
+ const visibleSubs = subButtons.filter((b) => !isExpandRow(b))
203
+
204
+ return {
205
+ visible: visibleSubs.length,
206
+ total: visibleSubs.length + hiddenCount,
207
+ hasExpandRow,
208
+ names: visibleSubs.map((b) => getText(b)),
209
+ }
210
+ }
211
+
212
+ async clickSubResource(displayName: string): Promise<void> {
213
+ const subButtons = Array.from(
214
+ document.querySelectorAll<HTMLButtonElement>(
215
+ 'button[class*="border-l-2"]',
216
+ ),
217
+ )
218
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
219
+ const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()
220
+ const target = subButtons.find(
221
+ (b) =>
222
+ getText(b) === displayName && !getText(b).trimStart().startsWith('...'),
223
+ )
224
+ if (!target) {
225
+ throw new Error(
226
+ `Sub-resource "${displayName}" not found in search result rows`,
227
+ )
228
+ }
229
+ await this.user.click(target)
230
+ }
231
+
232
+ async expandSubResources(): Promise<void> {
233
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
234
+ const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()
235
+ const expandButtons = Array.from(
236
+ document.querySelectorAll<HTMLButtonElement>(
237
+ 'button[class*="border-l-2"]',
238
+ ),
239
+ ).filter((b) => getText(b).trimStart().startsWith('...'))
240
+ if (expandButtons.length === 0) {
241
+ throw new Error('No expand ("...N more") button found')
242
+ }
243
+ await this.user.click(expandButtons[0]!)
244
+ }
245
+
246
+ /**
247
+ * Get the main catalog table (first table on page, skipping sub-resource tables in detail panel).
248
+ */
249
+ private getCatalogTable(): HTMLElement | null {
250
+ const tables = screen.queryAllByRole('table')
251
+ // The catalog table is the first table; sub-resource tables appear later in the detail panel
252
+ return tables[0] ?? null
253
+ }
254
+ }
@@ -0,0 +1,49 @@
1
+ import { screen } from '@testing-library/react'
2
+
3
+ export interface GlobalError {
4
+ title: string
5
+ message: string
6
+ element: HTMLElement
7
+ }
8
+
9
+ /**
10
+ * Find the global error on page.
11
+ *
12
+ * Checks for:
13
+ * 1. Our custom DefaultErrorComponent (role="alert" with "Ooops!")
14
+ * 2. TanStack Router's built-in error boundary ("Something went wrong!")
15
+ *
16
+ * Throws with page content dump if no error is found — makes debugging easier.
17
+ */
18
+ export function getGlobalError(): GlobalError {
19
+ // Check our custom error component first
20
+ const alertElement = screen.queryByRole('alert')
21
+ if (alertElement) {
22
+ const messageEl = alertElement.querySelector('i')
23
+ return {
24
+ title: 'Ooops!',
25
+ message: messageEl?.textContent ?? alertElement.textContent,
26
+ element: alertElement,
27
+ }
28
+ }
29
+
30
+ // Check TanStack Router's built-in error boundary
31
+ const bodyText = document.body.textContent
32
+ if (bodyText.includes('Something went wrong')) {
33
+ const codeEl = document.querySelector('code')
34
+ const errorContainer =
35
+ document.querySelector('[style*="padding"]') ?? document.body
36
+ return {
37
+ title: 'Something went wrong!',
38
+ message: codeEl?.textContent ?? bodyText,
39
+ element: errorContainer as HTMLElement,
40
+ }
41
+ }
42
+
43
+ // No error found — dump page for debugging
44
+ const body = document.body.innerHTML
45
+ const snippet = body.length > 2000 ? body.slice(0, 2000) + '...' : body
46
+ throw new Error(
47
+ `No global error found on page. Expected a role="alert" or "Something went wrong" element.\n\nPage content:\n${snippet}`,
48
+ )
49
+ }