@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 @@
1
+ {"version":3,"file":"CatalogTools.js","sources":["../../../src/tools/CatalogTools.ts"],"sourcesContent":["import { fireEvent, screen, within } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\n\nexport interface TableRow {\n name: string\n description: string\n}\n\nexport class CatalogTools {\n private user = userEvent.setup()\n\n /**\n * Click an app row by display name.\n * Works against both the table/grid view and the launcher home (#38), which\n * renders app rows as buttons rather than a <table>.\n * Throws with list of visible apps if name not found.\n */\n async openApp(name: string): Promise<void> {\n const table = this.getCatalogTable()\n if (table) {\n const rows = within(table).getAllByRole('row')\n for (const row of rows) {\n const nameEl = row.querySelector('.font-medium')\n if (nameEl?.textContent.trim() === name) {\n await this.user.click(row)\n return\n }\n }\n } else {\n // Launcher home: rows are buttons titled \"View <name>\".\n const btn = screen.queryByTitle(`View ${name}`)\n if (btn) {\n await this.user.click(btn)\n return\n }\n }\n\n const visibleNames = this.getTableData().map((r) => r.name)\n throw new Error(\n `App \"${name}\" not found. Visible apps: [${visibleNames.join(', ')}]`,\n )\n }\n\n /**\n * Type into the search input.\n */\n async search(text: string): Promise<void> {\n const input = screen.getByLabelText('Search apps')\n fireEvent.change(input, { target: { value: text } })\n }\n\n /** The catalog search input. */\n getSearchInput(): HTMLInputElement {\n return screen.getByLabelText<HTMLInputElement>('Search apps')\n }\n\n /**\n * Parse the catalog table into structured data.\n * Skips group header rows (colspan rows).\n */\n getTableData(): TableRow[] {\n const table = this.getCatalogTable()\n if (!table) {\n // Check if there's a global error — throw with details for debugging\n const bodyText = document.body.textContent\n if (\n bodyText.includes('Something went wrong') ||\n bodyText.includes('Ooops')\n ) {\n throw new Error(\n `Cannot read table — global error on page: ${bodyText.slice(0, 500)}`,\n )\n }\n // Launcher home (#38): no <table>; read the app row buttons instead.\n const launcherRows = this.getLauncherRows()\n if (launcherRows.length > 0) return launcherRows\n throw new Error('No table or launcher rows found on page')\n }\n\n const rows = within(table).getAllByRole('row')\n const result: TableRow[] = []\n\n for (const row of rows) {\n const cells = within(row).queryAllByRole('cell')\n // Skip header rows and group header rows (single cell with colspan)\n if (cells.length < 2) continue\n\n const nameEl = cells[0]?.querySelector('.font-medium')\n if (!nameEl) continue\n\n result.push({\n name: nameEl.textContent.trim(),\n description: cells[1]?.textContent.trim() ?? '',\n })\n }\n\n return result\n }\n\n /**\n * The secondary \"open in new tab\" launch link for a row, by app name.\n * Returns null if the row has no launch link.\n */\n getLaunchLink(name: string): HTMLAnchorElement | null {\n // The launch link's aria-label starts with \"Open <name> in a new tab\" and\n // may append the destination URL, e.g. \"… in a new tab (foo.example.com)\".\n return screen.queryByLabelText<HTMLAnchorElement>(\n new RegExp(`^Open ${name} in a new tab`),\n )\n }\n\n /**\n * Whether the right detail panel is currently visible.\n */\n isDetailPanelOpen(): boolean {\n return !!screen.queryByLabelText('Close details panel')\n }\n\n /** Whether the \"Show Deprecated Apps\" toggle is currently checked. */\n isShowDeprecatedChecked(): boolean {\n const cb = screen.getByRole('checkbox', { name: /Show Deprecated Apps/i })\n return (\n cb.getAttribute('aria-checked') === 'true' ||\n cb.getAttribute('data-state') === 'checked'\n )\n }\n\n /** Whether the \"showing deprecated matches\" fallback notice is visible. */\n hasDeprecatedFallbackNotice(): boolean {\n return !!screen.queryByText(/showing deprecated matches/i)\n }\n\n /** Whether the \"No apps found\" empty state or search-morph \"No results for\" is visible. */\n isEmptyStateVisible(): boolean {\n return (\n !!screen.queryByText(/No apps found/i) ||\n !!screen.queryByText(/No results for/i)\n )\n }\n\n /**\n * Whether the onboarding/welcome card is visible.\n */\n isOnboardingVisible(): boolean {\n return !!screen.queryByText('Welcome to App Catalog')\n }\n\n /**\n * Read app rows from the launcher home (#38): buttons titled \"View <name>\".\n * Name is the first `.font-semibold`/`.font-bold` span; description the\n * `.text-muted-foreground` span. Deduped by name (an app can appear both in\n * \"Your apps\" and \"Browse all\").\n */\n private getLauncherRows(): TableRow[] {\n const buttons = Array.from(\n document.querySelectorAll<HTMLElement>('button[title^=\"View \"]'),\n )\n const seen = new Set<string>()\n const result: TableRow[] = []\n for (const btn of buttons) {\n const name = (btn.getAttribute('title') ?? '')\n .replace(/^View\\s+/, '')\n .trim()\n if (!name || seen.has(name)) continue\n seen.add(name)\n const descEl = btn.querySelector('.text-muted-foreground')\n result.push({ name, description: descEl?.textContent.trim() ?? '' })\n }\n return result\n }\n\n /**\n * Get expandable subresource rows shown under parents in search results.\n * Returns null if no sub-rows are visible.\n */\n getSubResourceRows(): {\n visible: number\n total: number\n hasExpandRow: boolean\n names: string[]\n } | null {\n const subButtons = Array.from(\n document.querySelectorAll<HTMLButtonElement>(\n 'button[class*=\"border-l-2\"]',\n ),\n )\n if (subButtons.length === 0) return null\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()\n const isExpandRow = (el: HTMLButtonElement) => getText(el).startsWith('...')\n\n const expandButton = subButtons.find(isExpandRow)\n const hasExpandRow = !!expandButton\n\n let hiddenCount = 0\n if (expandButton) {\n const match = /\\d+/.exec(getText(expandButton))\n hiddenCount = match?.[0] ? parseInt(match[0], 10) : 0\n }\n\n const visibleSubs = subButtons.filter((b) => !isExpandRow(b))\n\n return {\n visible: visibleSubs.length,\n total: visibleSubs.length + hiddenCount,\n hasExpandRow,\n names: visibleSubs.map((b) => getText(b)),\n }\n }\n\n async clickSubResource(displayName: string): Promise<void> {\n const subButtons = Array.from(\n document.querySelectorAll<HTMLButtonElement>(\n 'button[class*=\"border-l-2\"]',\n ),\n )\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()\n const target = subButtons.find(\n (b) =>\n getText(b) === displayName && !getText(b).trimStart().startsWith('...'),\n )\n if (!target) {\n throw new Error(\n `Sub-resource \"${displayName}\" not found in search result rows`,\n )\n }\n await this.user.click(target)\n }\n\n async expandSubResources(): Promise<void> {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()\n const expandButtons = Array.from(\n document.querySelectorAll<HTMLButtonElement>(\n 'button[class*=\"border-l-2\"]',\n ),\n ).filter((b) => getText(b).trimStart().startsWith('...'))\n if (expandButtons.length === 0) {\n throw new Error('No expand (\"...N more\") button found')\n }\n await this.user.click(expandButtons[0]!)\n }\n\n /**\n * Get the main catalog table (first table on page, skipping sub-resource tables in detail panel).\n */\n private getCatalogTable(): HTMLElement | null {\n const tables = screen.queryAllByRole('table')\n // The catalog table is the first table; sub-resource tables appear later in the detail panel\n return tables[0] ?? null\n }\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ export interface GlobalError {
2
+ title: string;
3
+ message: string;
4
+ element: HTMLElement;
5
+ }
6
+ /**
7
+ * Find the global error on page.
8
+ *
9
+ * Checks for:
10
+ * 1. Our custom DefaultErrorComponent (role="alert" with "Ooops!")
11
+ * 2. TanStack Router's built-in error boundary ("Something went wrong!")
12
+ *
13
+ * Throws with page content dump if no error is found — makes debugging easier.
14
+ */
15
+ export declare function getGlobalError(): GlobalError;
@@ -0,0 +1,34 @@
1
+ import { screen } from "@testing-library/react";
2
+ function getGlobalError() {
3
+ const alertElement = screen.queryByRole("alert");
4
+ if (alertElement) {
5
+ const messageEl = alertElement.querySelector("i");
6
+ return {
7
+ title: "Ooops!",
8
+ message: (messageEl == null ? void 0 : messageEl.textContent) ?? alertElement.textContent,
9
+ element: alertElement
10
+ };
11
+ }
12
+ const bodyText = document.body.textContent;
13
+ if (bodyText.includes("Something went wrong")) {
14
+ const codeEl = document.querySelector("code");
15
+ const errorContainer = document.querySelector('[style*="padding"]') ?? document.body;
16
+ return {
17
+ title: "Something went wrong!",
18
+ message: (codeEl == null ? void 0 : codeEl.textContent) ?? bodyText,
19
+ element: errorContainer
20
+ };
21
+ }
22
+ const body = document.body.innerHTML;
23
+ const snippet = body.length > 2e3 ? body.slice(0, 2e3) + "..." : body;
24
+ throw new Error(
25
+ `No global error found on page. Expected a role="alert" or "Something went wrong" element.
26
+
27
+ Page content:
28
+ ${snippet}`
29
+ );
30
+ }
31
+ export {
32
+ getGlobalError
33
+ };
34
+ //# sourceMappingURL=ErrorTools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ErrorTools.js","sources":["../../../src/tools/ErrorTools.ts"],"sourcesContent":["import { screen } from '@testing-library/react'\n\nexport interface GlobalError {\n title: string\n message: string\n element: HTMLElement\n}\n\n/**\n * Find the global error on page.\n *\n * Checks for:\n * 1. Our custom DefaultErrorComponent (role=\"alert\" with \"Ooops!\")\n * 2. TanStack Router's built-in error boundary (\"Something went wrong!\")\n *\n * Throws with page content dump if no error is found — makes debugging easier.\n */\nexport function getGlobalError(): GlobalError {\n // Check our custom error component first\n const alertElement = screen.queryByRole('alert')\n if (alertElement) {\n const messageEl = alertElement.querySelector('i')\n return {\n title: 'Ooops!',\n message: messageEl?.textContent ?? alertElement.textContent,\n element: alertElement,\n }\n }\n\n // Check TanStack Router's built-in error boundary\n const bodyText = document.body.textContent\n if (bodyText.includes('Something went wrong')) {\n const codeEl = document.querySelector('code')\n const errorContainer =\n document.querySelector('[style*=\"padding\"]') ?? document.body\n return {\n title: 'Something went wrong!',\n message: codeEl?.textContent ?? bodyText,\n element: errorContainer as HTMLElement,\n }\n }\n\n // No error found — dump page for debugging\n const body = document.body.innerHTML\n const snippet = body.length > 2000 ? body.slice(0, 2000) + '...' : body\n throw new Error(\n `No global error found on page. Expected a role=\"alert\" or \"Something went wrong\" element.\\n\\nPage content:\\n${snippet}`,\n )\n}\n"],"names":[],"mappings":";AAiBO,SAAS,iBAA8B;AAE5C,QAAM,eAAe,OAAO,YAAY,OAAO;AAC/C,MAAI,cAAc;AAChB,UAAM,YAAY,aAAa,cAAc,GAAG;AAChD,WAAO;AAAA,MACL,OAAO;AAAA,MACP,UAAS,uCAAW,gBAAe,aAAa;AAAA,MAChD,SAAS;AAAA,IAAA;AAAA,EAEb;AAGA,QAAM,WAAW,SAAS,KAAK;AAC/B,MAAI,SAAS,SAAS,sBAAsB,GAAG;AAC7C,UAAM,SAAS,SAAS,cAAc,MAAM;AAC5C,UAAM,iBACJ,SAAS,cAAc,oBAAoB,KAAK,SAAS;AAC3D,WAAO;AAAA,MACL,OAAO;AAAA,MACP,UAAS,iCAAQ,gBAAe;AAAA,MAChC,SAAS;AAAA,IAAA;AAAA,EAEb;AAGA,QAAM,OAAO,SAAS,KAAK;AAC3B,QAAM,UAAU,KAAK,SAAS,MAAO,KAAK,MAAM,GAAG,GAAI,IAAI,QAAQ;AACnE,QAAM,IAAI;AAAA,IACR;AAAA;AAAA;AAAA,EAA+G,OAAO;AAAA,EAAA;AAE1H;"}
@@ -0,0 +1,6 @@
1
+ export declare class GalleryTools {
2
+ private user;
3
+ clickNext(): Promise<void>;
4
+ clickPrev(): Promise<void>;
5
+ isOpen(): boolean;
6
+ }
@@ -0,0 +1,22 @@
1
+ import { screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ class GalleryTools {
4
+ constructor() {
5
+ this.user = userEvent.setup();
6
+ }
7
+ async clickNext() {
8
+ const nextBtn = screen.getByRole("button", { name: /next/i });
9
+ await this.user.click(nextBtn);
10
+ }
11
+ async clickPrev() {
12
+ const prevBtn = screen.getByRole("button", { name: /prev/i });
13
+ await this.user.click(prevBtn);
14
+ }
15
+ isOpen() {
16
+ return !!(screen.queryByRole("button", { name: /next/i }) && screen.queryByRole("button", { name: /prev/i }));
17
+ }
18
+ }
19
+ export {
20
+ GalleryTools
21
+ };
22
+ //# sourceMappingURL=GalleryTools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GalleryTools.js","sources":["../../../src/tools/GalleryTools.ts"],"sourcesContent":["import { screen } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\n\nexport class GalleryTools {\n private user = userEvent.setup()\n\n async clickNext(): Promise<void> {\n const nextBtn = screen.getByRole('button', { name: /next/i })\n await this.user.click(nextBtn)\n }\n\n async clickPrev(): Promise<void> {\n const prevBtn = screen.getByRole('button', { name: /prev/i })\n await this.user.click(prevBtn)\n }\n\n isOpen(): boolean {\n // Detect the SCREENSHOT gallery specifically — not just any [role=\"dialog\"]\n // (the app detail card is also a dialog now, #38). The gallery is the only\n // dialog with next/prev navigation controls.\n return !!(\n screen.queryByRole('button', { name: /next/i }) &&\n screen.queryByRole('button', { name: /prev/i })\n )\n }\n}\n"],"names":[],"mappings":";;AAGO,MAAM,aAAa;AAAA,EAAnB,cAAA;AACL,SAAQ,OAAO,UAAU,MAAA;AAAA,EAAM;AAAA,EAE/B,MAAM,YAA2B;AAC/B,UAAM,UAAU,OAAO,UAAU,UAAU,EAAE,MAAM,SAAS;AAC5D,UAAM,KAAK,KAAK,MAAM,OAAO;AAAA,EAC/B;AAAA,EAEA,MAAM,YAA2B;AAC/B,UAAM,UAAU,OAAO,UAAU,UAAU,EAAE,MAAM,SAAS;AAC5D,UAAM,KAAK,KAAK,MAAM,OAAO;AAAA,EAC/B;AAAA,EAEA,SAAkB;AAIhB,WAAO,CAAC,EACN,OAAO,YAAY,UAAU,EAAE,MAAM,QAAA,CAAS,KAC9C,OAAO,YAAY,UAAU,EAAE,MAAM,SAAS;AAAA,EAElD;AACF;"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Suppress console output matching the given patterns for the current test only.
3
+ * Automatically resumes after the test completes (pass or fail).
4
+ *
5
+ * Usage:
6
+ * suppressConsole(/Failed to fetch/)
7
+ * suppressConsole([/Failed to fetch/, /error boundary/])
8
+ */
9
+ export declare function suppressConsole(patterns: RegExp | RegExp[]): void;
10
+ /**
11
+ * Suppress console output matching the given patterns for ALL tests in this file.
12
+ * Call at the top level of `describe()` or at module scope.
13
+ * Does NOT auto-clear between tests.
14
+ *
15
+ * Usage:
16
+ * suppressConsoleGlobal(/Background sync failed/)
17
+ * suppressConsoleGlobal([/Background sync/, /some other noise/])
18
+ */
19
+ export declare function suppressConsoleGlobal(patterns: RegExp | RegExp[]): void;
@@ -0,0 +1,44 @@
1
+ import { afterEach } from "vitest";
2
+ const globalPatterns = [];
3
+ let testPatterns = [];
4
+ const originals = {
5
+ log: console.log.bind(console),
6
+ warn: console.warn.bind(console),
7
+ error: console.error.bind(console),
8
+ debug: console.debug.bind(console)
9
+ };
10
+ function isSuppressed(args) {
11
+ const all = [...globalPatterns, ...testPatterns];
12
+ if (all.length === 0) return false;
13
+ const text = args.map(String).join(" ");
14
+ return all.some((p) => p.test(text));
15
+ }
16
+ let installed = false;
17
+ function installOnce() {
18
+ if (installed) return;
19
+ installed = true;
20
+ for (const method of ["log", "warn", "error", "debug"]) {
21
+ const original = originals[method];
22
+ console[method] = (...args) => {
23
+ if (!isSuppressed(args)) original(...args);
24
+ };
25
+ }
26
+ afterEach(() => {
27
+ testPatterns = [];
28
+ });
29
+ }
30
+ function suppressConsole(patterns) {
31
+ installOnce();
32
+ const arr = Array.isArray(patterns) ? patterns : [patterns];
33
+ testPatterns.push(...arr);
34
+ }
35
+ function suppressConsoleGlobal(patterns) {
36
+ installOnce();
37
+ const arr = Array.isArray(patterns) ? patterns : [patterns];
38
+ globalPatterns.push(...arr);
39
+ }
40
+ export {
41
+ suppressConsole,
42
+ suppressConsoleGlobal
43
+ };
44
+ //# sourceMappingURL=suppressConsole.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suppressConsole.js","sources":["../../../src/tools/suppressConsole.ts"],"sourcesContent":["import { afterEach } from 'vitest'\n\nconst globalPatterns: RegExp[] = []\nlet testPatterns: RegExp[] = []\n\nconst originals = {\n log: console.log.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n debug: console.debug.bind(console),\n}\n\nfunction isSuppressed(args: unknown[]): boolean {\n const all = [...globalPatterns, ...testPatterns]\n if (all.length === 0) return false\n const text = args.map(String).join(' ')\n return all.some((p) => p.test(text))\n}\n\nlet installed = false\n\nfunction installOnce() {\n if (installed) return\n installed = true\n\n for (const method of ['log', 'warn', 'error', 'debug'] as const) {\n const original = originals[method]\n console[method] = (...args: unknown[]) => {\n if (!isSuppressed(args)) original(...args)\n }\n }\n\n // Auto-clear test-scoped patterns after each test (global patterns persist)\n afterEach(() => {\n testPatterns = []\n })\n}\n\n/**\n * Suppress console output matching the given patterns for the current test only.\n * Automatically resumes after the test completes (pass or fail).\n *\n * Usage:\n * suppressConsole(/Failed to fetch/)\n * suppressConsole([/Failed to fetch/, /error boundary/])\n */\nexport function suppressConsole(patterns: RegExp | RegExp[]): void {\n installOnce()\n const arr = Array.isArray(patterns) ? patterns : [patterns]\n testPatterns.push(...arr)\n}\n\n/**\n * Suppress console output matching the given patterns for ALL tests in this file.\n * Call at the top level of `describe()` or at module scope.\n * Does NOT auto-clear between tests.\n *\n * Usage:\n * suppressConsoleGlobal(/Background sync failed/)\n * suppressConsoleGlobal([/Background sync/, /some other noise/])\n */\nexport function suppressConsoleGlobal(patterns: RegExp | RegExp[]): void {\n installOnce()\n const arr = Array.isArray(patterns) ? patterns : [patterns]\n globalPatterns.push(...arr)\n}\n"],"names":[],"mappings":";AAEA,MAAM,iBAA2B,CAAA;AACjC,IAAI,eAAyB,CAAA;AAE7B,MAAM,YAAY;AAAA,EAChB,KAAK,QAAQ,IAAI,KAAK,OAAO;AAAA,EAC7B,MAAM,QAAQ,KAAK,KAAK,OAAO;AAAA,EAC/B,OAAO,QAAQ,MAAM,KAAK,OAAO;AAAA,EACjC,OAAO,QAAQ,MAAM,KAAK,OAAO;AACnC;AAEA,SAAS,aAAa,MAA0B;AAC9C,QAAM,MAAM,CAAC,GAAG,gBAAgB,GAAG,YAAY;AAC/C,MAAI,IAAI,WAAW,EAAG,QAAO;AAC7B,QAAM,OAAO,KAAK,IAAI,MAAM,EAAE,KAAK,GAAG;AACtC,SAAO,IAAI,KAAK,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC;AACrC;AAEA,IAAI,YAAY;AAEhB,SAAS,cAAc;AACrB,MAAI,UAAW;AACf,cAAY;AAEZ,aAAW,UAAU,CAAC,OAAO,QAAQ,SAAS,OAAO,GAAY;AAC/D,UAAM,WAAW,UAAU,MAAM;AACjC,YAAQ,MAAM,IAAI,IAAI,SAAoB;AACxC,UAAI,CAAC,aAAa,IAAI,EAAG,UAAS,GAAG,IAAI;AAAA,IAC3C;AAAA,EACF;AAGA,YAAU,MAAM;AACd,mBAAe,CAAA;AAAA,EACjB,CAAC;AACH;AAUO,SAAS,gBAAgB,UAAmC;AACjE,cAAA;AACA,QAAM,MAAM,MAAM,QAAQ,QAAQ,IAAI,WAAW,CAAC,QAAQ;AAC1D,eAAa,KAAK,GAAG,GAAG;AAC1B;AAWO,SAAS,sBAAsB,UAAmC;AACvE,cAAA;AACA,QAAM,MAAM,MAAM,QAAQ,QAAQ,IAAI,WAAW,CAAC,QAAQ;AAC1D,iBAAe,KAAK,GAAG,GAAG;AAC5B;"}
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@igstack/app-catalog-test-kit",
3
+ "version": "0.18.6",
4
+ "description": "Integration test harness for App Catalog: mock backend, mock network and page objects",
5
+ "homepage": "https://github.com/lislon/app-catalog",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/lislon/app-catalog.git",
9
+ "directory": "packages/test-kit"
10
+ },
11
+ "license": "MIT",
12
+ "author": "Igor Golovin",
13
+ "sideEffects": [
14
+ "./src/setup/*",
15
+ "./dist/esm/setup/*"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": {
20
+ "my-custom-condition": "./src/index.ts",
21
+ "import": {
22
+ "types": "./dist/esm/index.d.ts",
23
+ "default": "./dist/esm/index.js"
24
+ }
25
+ },
26
+ "./setup": {
27
+ "my-custom-condition": "./src/setup/testSetup.ts",
28
+ "import": {
29
+ "types": "./dist/esm/setup/testSetup.d.ts",
30
+ "default": "./dist/esm/setup/testSetup.js"
31
+ }
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "module": "dist/esm/index.js",
36
+ "types": "dist/esm/index.d.ts",
37
+ "files": [
38
+ "dist",
39
+ "src"
40
+ ],
41
+ "scripts": {
42
+ "build": "vite build",
43
+ "clean": "premove ./dist ./coverage ./dist-ts",
44
+ "compile": "tsc --build",
45
+ "fix": "prettier --write ./src ./tests && eslint --fix ./src ./tests",
46
+ "test": "pnpm run test:unit",
47
+ "test:eslint": "eslint ./src ./tests --max-warnings=0",
48
+ "test:types": "tsc --noEmit",
49
+ "test:unit": "vitest --run",
50
+ "test:unit:dev": "vitest"
51
+ },
52
+ "dependencies": {
53
+ "@tanstack/react-query": "catalog:",
54
+ "@tanstack/react-router": "catalog:",
55
+ "@testing-library/jest-dom": "catalog:",
56
+ "@testing-library/react": "catalog:",
57
+ "@testing-library/user-event": "^14.6.1",
58
+ "@trpc/client": "catalog:",
59
+ "fake-indexeddb": "^6.2.4",
60
+ "msw": "^2.12.14",
61
+ "msw-trpc": "^2.0.1",
62
+ "superjson": "catalog:"
63
+ },
64
+ "devDependencies": {
65
+ "@igstack/app-catalog-backend-core": "workspace:*",
66
+ "@igstack/app-catalog-frontend-core": "workspace:*",
67
+ "@tanstack/vite-config": "catalog:",
68
+ "@types/node": "catalog:",
69
+ "@types/react": "catalog:",
70
+ "@vitejs/plugin-react": "catalog:",
71
+ "jsdom": "^25.0.1",
72
+ "typescript": "catalog:",
73
+ "vite": "catalog:",
74
+ "vitest": "catalog:"
75
+ },
76
+ "peerDependencies": {
77
+ "@igstack/app-catalog-backend-core": "workspace:^",
78
+ "@igstack/app-catalog-frontend-core": "workspace:^",
79
+ "jsdom": ">=25",
80
+ "react": "catalog:",
81
+ "react-dom": "catalog:",
82
+ "vitest": "catalog:"
83
+ },
84
+ "engines": {
85
+ "node": ">=16"
86
+ }
87
+ }
@@ -0,0 +1,14 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import type { MockDb } from '../mock-backend/MockDb'
3
+
4
+ export class MockBackendVerifier {
5
+ constructor(readonly db: MockDb) {}
6
+
7
+ apps(): Resource[] {
8
+ return this.db.getResources()
9
+ }
10
+
11
+ getApp(slug: string): Resource {
12
+ return this.db.getResource(slug)
13
+ }
14
+ }
@@ -0,0 +1,212 @@
1
+ import { render, screen, waitFor } from '@testing-library/react'
2
+ import { createMemoryHistory } from '@tanstack/react-router'
3
+ import { createTRPCClient, httpLink } from '@trpc/client'
4
+ import { QueryClient } from '@tanstack/react-query'
5
+ import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
6
+ import { setupServer } from 'msw/node'
7
+ import type { SetupServer } from 'msw/node'
8
+
9
+ import {
10
+ AcDb,
11
+ App,
12
+ SEARCH_STORAGE_KEY,
13
+ createAcRouter,
14
+ } from '@igstack/app-catalog-frontend-core/internal'
15
+
16
+ import { MockDb } from '../mock-backend/MockDb'
17
+ import { MockUserContext } from '../mock-backend/MockUserContext'
18
+ import { MockService } from '../mock-backend/MockService'
19
+ import {
20
+ MockBackendConfigurer,
21
+ resetConfigurerCounter,
22
+ } from '../mock-backend/MockBackendConfigurer'
23
+ import { BrowserStateCfg } from '../mock-backend/BrowserStateCfg'
24
+ import { NetworkConfigurerCfg } from '../mock-network/NetworkConfigurerCfg'
25
+ import type { ConfigurerContext, Magazine } from '../mock-backend/magazines'
26
+ import { makeNetworkReplyWithCatalog } from '../mock-network/makeNetworkReplyWithCatalog'
27
+ import { MockBackendVerifier } from './MockBackendVerifier'
28
+ import { getGlobalError } from '../tools/ErrorTools'
29
+ import type { GlobalError } from '../tools/ErrorTools'
30
+ import { browserState } from '../tools/BrowserState'
31
+ import { CatalogTools } from '../tools/CatalogTools'
32
+ import { AppDetailTools } from '../tools/AppDetailTools'
33
+ import { GalleryTools } from '../tools/GalleryTools'
34
+
35
+ export interface GivenResult {
36
+ ui: UiTools
37
+ backend: MockBackendVerifier
38
+ router: ReturnType<typeof createAcRouter>
39
+ }
40
+
41
+ export interface UiTools {
42
+ catalog: CatalogTools
43
+ app: AppDetailTools
44
+ gallery: GalleryTools
45
+ globalError: () => GlobalError
46
+ }
47
+
48
+ // Track active resources for cleanup
49
+ let activeServer: SetupServer | null = null
50
+ let activeDb: AcDb | null = null
51
+
52
+ export async function cleanupTestResources(): Promise<void> {
53
+ if (activeServer) {
54
+ activeServer.close()
55
+ activeServer = null
56
+ }
57
+ if (activeDb) {
58
+ activeDb.close()
59
+ await activeDb.delete()
60
+ activeDb = null
61
+ }
62
+ }
63
+
64
+ export async function given(
65
+ magazine: Magazine,
66
+ opts: { initialRoute?: string; seedSearch?: string } = {},
67
+ ): Promise<GivenResult> {
68
+ // Clean up any previous resources
69
+ await cleanupTestResources()
70
+ resetConfigurerCounter()
71
+
72
+ // 1. Create cfg objects (data carriers)
73
+ const db = new MockDb()
74
+ const userContext = new MockUserContext()
75
+ const backendCfg = new MockBackendConfigurer(db, userContext)
76
+ const browserStateCfg = new BrowserStateCfg()
77
+ const networkCfg = new NetworkConfigurerCfg()
78
+
79
+ const ctx: ConfigurerContext = { backendCfg, browserStateCfg, networkCfg }
80
+
81
+ // 2. Run magazine configurer — populates all three cfg objects
82
+ magazine(ctx)
83
+
84
+ // 3. Materialize backend service
85
+ const service = new MockService(db, userContext)
86
+
87
+ // 4. Apply browser state using data from backend service
88
+ if (browserStateCfg.shouldDismissOnboarding) {
89
+ browserState.dismissOnboarding()
90
+ }
91
+ if (browserStateCfg.shouldSeedOfflineData) {
92
+ await browserState.seedCatalogCache(service.getAppCatalogData())
93
+ }
94
+ for (const [key, value] of browserStateCfg.localStorageItems) {
95
+ localStorage.setItem(key, value)
96
+ }
97
+
98
+ // Seed the persisted catalog search (#27): simulates a returning user who had
99
+ // typed a query before navigating. Backs the same sessionStorage key the
100
+ // provider reads, so the search input repopulates without any `?q=` in the URL.
101
+ if (opts.seedSearch !== undefined) {
102
+ sessionStorage.setItem(SEARCH_STORAGE_KEY, opts.seedSearch)
103
+ }
104
+
105
+ // 5. Build network catalog from backend service, then apply overrides
106
+ const catalog = makeNetworkReplyWithCatalog(service)
107
+ for (const override of networkCfg.overrides) {
108
+ override(catalog, service)
109
+ }
110
+
111
+ // 6. Set up MSW server
112
+ const server = setupServer(...catalog.getHandlers())
113
+ server.listen({
114
+ onUnhandledRequest: (req) => {
115
+ console.warn(`[MSW] Unhandled: ${req.method} ${req.url}`)
116
+ },
117
+ })
118
+ activeServer = server
119
+
120
+ // 7. Create real app infrastructure
121
+ const trpcClient = createTRPCClient<TRPCRouter>({
122
+ links: [
123
+ httpLink({
124
+ url: 'http://localhost/api/trpc',
125
+ }),
126
+ ],
127
+ })
128
+
129
+ const acDb = new AcDb()
130
+ activeDb = acDb
131
+ const queryClient = new QueryClient({
132
+ defaultOptions: {
133
+ queries: {
134
+ retry: false,
135
+ gcTime: 0,
136
+ meta: { db: acDb, trpcClient },
137
+ },
138
+ },
139
+ })
140
+
141
+ const router = createAcRouter({
142
+ history: createMemoryHistory({
143
+ initialEntries: [opts.initialRoute ?? '/catalog/apps'],
144
+ }),
145
+ context: {
146
+ queryClient,
147
+ trpcClient,
148
+ db: acDb,
149
+ plugins: [],
150
+ boostrapHealth: {},
151
+ },
152
+ })
153
+
154
+ // 7b. Seed auth user in localStorage for optimistic rendering
155
+ // (mirrors real-app behavior where localStorage caches the user after first login)
156
+ const sessionResponse = service.getSessionResponse()
157
+ if (
158
+ sessionResponse &&
159
+ typeof sessionResponse === 'object' &&
160
+ 'user' in sessionResponse
161
+ ) {
162
+ localStorage.setItem(
163
+ 'ac_auth_user',
164
+ JSON.stringify((sessionResponse as { user: unknown }).user),
165
+ )
166
+ }
167
+
168
+ // 8. Render
169
+ render(
170
+ <App
171
+ router={router}
172
+ queryClient={queryClient}
173
+ trpcClient={trpcClient}
174
+ db={acDb}
175
+ />,
176
+ )
177
+
178
+ // 9. Wait for initial load (either data loaded or error shown)
179
+ await waitFor(
180
+ () => {
181
+ const hasAlert = screen.queryByRole('alert')
182
+ const hasTable = screen.queryAllByRole('table').length > 0
183
+ const hasSearchbox = screen.queryByLabelText('Search apps')
184
+ const hasTanstackError = document.body.textContent.includes(
185
+ 'Something went wrong',
186
+ )
187
+ const hasNotFound = document.body.textContent.includes('404 Not Found')
188
+ if (
189
+ !hasAlert &&
190
+ !hasTable &&
191
+ !hasSearchbox &&
192
+ !hasTanstackError &&
193
+ !hasNotFound
194
+ ) {
195
+ throw new Error('Still loading...')
196
+ }
197
+ },
198
+ { timeout: 10000 },
199
+ )
200
+
201
+ // 10. Return tools
202
+ const backend = new MockBackendVerifier(db)
203
+
204
+ const ui: UiTools = {
205
+ catalog: new CatalogTools(),
206
+ app: new AppDetailTools(),
207
+ gallery: new GalleryTools(),
208
+ globalError: () => getGlobalError(),
209
+ }
210
+
211
+ return { ui, backend, router }
212
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Integration test harness for App Catalog.
3
+ *
4
+ * `given(...)` mounts the real `App` against a mock backend and mock network,
5
+ * and hands back page objects (`ui.catalog`, `ui.app`, ...) to drive it with.
6
+ * Consumers supply `vitest`, `jsdom` and the setup file:
7
+ *
8
+ * // vite.config.ts
9
+ * test: {
10
+ * environment: 'jsdom',
11
+ * setupFiles: ['@igstack/app-catalog-test-kit/setup'],
12
+ * }
13
+ */
14
+ export { given, cleanupTestResources } from './harness/given'
15
+ export type { GivenResult, UiTools } from './harness/given'
16
+ export { MockBackendVerifier } from './harness/MockBackendVerifier'
17
+
18
+ export { magazine } from './mock-backend/magazines'
19
+ export type { ConfigurerContext, Magazine } from './mock-backend/magazines'
20
+ export {
21
+ MockBackendConfigurer,
22
+ resetConfigurerCounter,
23
+ } from './mock-backend/MockBackendConfigurer'
24
+ export { MockDb } from './mock-backend/MockDb'
25
+ export { MockService } from './mock-backend/MockService'
26
+ export { MockUserContext } from './mock-backend/MockUserContext'
27
+ export type { UserConfig } from './mock-backend/MockUserContext'
28
+ export { BrowserStateCfg } from './mock-backend/BrowserStateCfg'
29
+
30
+ export { NetworkConfigurerCfg } from './mock-network/NetworkConfigurerCfg'
31
+ export type { NetworkOverrideFn } from './mock-network/NetworkConfigurerCfg'
32
+ export { NetworkCatalog } from './mock-network/NetworkCatalog'
33
+ export type { NetworkInterceptor } from './mock-network/NetworkCatalog'
34
+ export { SharedNetwork } from './mock-network/SharedNetwork'
35
+ export { makeNetworkReplyWithCatalog } from './mock-network/makeNetworkReplyWithCatalog'
36
+ export * from './mock-network/errorFactories'
37
+
38
+ export { CatalogTools } from './tools/CatalogTools'
39
+ export { AppDetailTools } from './tools/AppDetailTools'
40
+ export { GalleryTools } from './tools/GalleryTools'
41
+ export { getGlobalError } from './tools/ErrorTools'
42
+ export type { GlobalError } from './tools/ErrorTools'
43
+ export { browserState } from './tools/BrowserState'
44
+ export { suppressConsole, suppressConsoleGlobal } from './tools/suppressConsole'
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Data-carrier that collects browser state configuration.
3
+ * Does not execute anything — `given()` materializes it after backend is ready.
4
+ */
5
+ export class BrowserStateCfg {
6
+ private _offlineData = false
7
+ private _onboardingDismissed = false
8
+ private _localStorage = new Map<string, string>()
9
+
10
+ /** Seed IndexedDB with current backend data (simulates returning user) */
11
+ withOfflineData(): void {
12
+ this._offlineData = true
13
+ }
14
+
15
+ /** Mark onboarding as dismissed */
16
+ dismissOnboarding(): void {
17
+ this._onboardingDismissed = true
18
+ }
19
+
20
+ /** Set an arbitrary localStorage item */
21
+ withLocalStorageItem(key: string, value: string): void {
22
+ this._localStorage.set(key, value)
23
+ }
24
+
25
+ // Read-only accessors for given() to materialize
26
+ get shouldSeedOfflineData(): boolean {
27
+ return this._offlineData
28
+ }
29
+ get shouldDismissOnboarding(): boolean {
30
+ return this._onboardingDismissed
31
+ }
32
+ get localStorageItems(): Map<string, string> {
33
+ return this._localStorage
34
+ }
35
+ }