@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.
- package/README.md +51 -0
- package/dist/esm/harness/MockBackendVerifier.d.ts +8 -0
- package/dist/esm/harness/MockBackendVerifier.js +15 -0
- package/dist/esm/harness/MockBackendVerifier.js.map +1 -0
- package/dist/esm/harness/given.d.ts +23 -0
- package/dist/esm/harness/given.js +144 -0
- package/dist/esm/harness/given.js.map +1 -0
- package/dist/esm/index.d.ts +38 -0
- package/dist/esm/index.js +45 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/mock-backend/BrowserStateCfg.d.ts +18 -0
- package/dist/esm/mock-backend/BrowserStateCfg.js +33 -0
- package/dist/esm/mock-backend/BrowserStateCfg.js.map +1 -0
- package/dist/esm/mock-backend/MockBackendConfigurer.d.ts +17 -0
- package/dist/esm/mock-backend/MockBackendConfigurer.js +80 -0
- package/dist/esm/mock-backend/MockBackendConfigurer.js.map +1 -0
- package/dist/esm/mock-backend/MockDb.d.ts +18 -0
- package/dist/esm/mock-backend/MockDb.js +56 -0
- package/dist/esm/mock-backend/MockDb.js.map +1 -0
- package/dist/esm/mock-backend/MockService.d.ts +10 -0
- package/dist/esm/mock-backend/MockService.js +16 -0
- package/dist/esm/mock-backend/MockService.js.map +1 -0
- package/dist/esm/mock-backend/MockUserContext.d.ts +11 -0
- package/dist/esm/mock-backend/MockUserContext.js +38 -0
- package/dist/esm/mock-backend/MockUserContext.js.map +1 -0
- package/dist/esm/mock-backend/magazines.d.ts +22 -0
- package/dist/esm/mock-backend/magazines.js +186 -0
- package/dist/esm/mock-backend/magazines.js.map +1 -0
- package/dist/esm/mock-network/NetworkCatalog.d.ts +12 -0
- package/dist/esm/mock-network/NetworkCatalog.js +24 -0
- package/dist/esm/mock-network/NetworkCatalog.js.map +1 -0
- package/dist/esm/mock-network/NetworkConfigurerCfg.d.ts +13 -0
- package/dist/esm/mock-network/NetworkConfigurerCfg.js +16 -0
- package/dist/esm/mock-network/NetworkConfigurerCfg.js.map +1 -0
- package/dist/esm/mock-network/SharedNetwork.d.ts +13 -0
- package/dist/esm/mock-network/SharedNetwork.js +149 -0
- package/dist/esm/mock-network/SharedNetwork.js.map +1 -0
- package/dist/esm/mock-network/errorFactories.d.ts +11 -0
- package/dist/esm/mock-network/errorFactories.js +22 -0
- package/dist/esm/mock-network/errorFactories.js.map +1 -0
- package/dist/esm/mock-network/makeNetworkReplyWithCatalog.d.ts +3 -0
- package/dist/esm/mock-network/makeNetworkReplyWithCatalog.js +17 -0
- package/dist/esm/mock-network/makeNetworkReplyWithCatalog.js.map +1 -0
- package/dist/esm/setup/polyfillLocalStorage.d.ts +0 -0
- package/dist/esm/setup/polyfillLocalStorage.js +25 -0
- package/dist/esm/setup/polyfillLocalStorage.js.map +1 -0
- package/dist/esm/setup/svgMock.d.ts +1 -0
- package/dist/esm/setup/testSetup.d.ts +0 -0
- package/dist/esm/setup/testSetup.js +58 -0
- package/dist/esm/setup/testSetup.js.map +1 -0
- package/dist/esm/tools/AppDetailTools.d.ts +63 -0
- package/dist/esm/tools/AppDetailTools.js +180 -0
- package/dist/esm/tools/AppDetailTools.js.map +1 -0
- package/dist/esm/tools/BrowserState.d.ts +25 -0
- package/dist/esm/tools/BrowserState.js +30 -0
- package/dist/esm/tools/BrowserState.js.map +1 -0
- package/dist/esm/tools/CatalogTools.d.ts +67 -0
- package/dist/esm/tools/CatalogTools.js +202 -0
- package/dist/esm/tools/CatalogTools.js.map +1 -0
- package/dist/esm/tools/ErrorTools.d.ts +15 -0
- package/dist/esm/tools/ErrorTools.js +34 -0
- package/dist/esm/tools/ErrorTools.js.map +1 -0
- package/dist/esm/tools/GalleryTools.d.ts +6 -0
- package/dist/esm/tools/GalleryTools.js +22 -0
- package/dist/esm/tools/GalleryTools.js.map +1 -0
- package/dist/esm/tools/suppressConsole.d.ts +19 -0
- package/dist/esm/tools/suppressConsole.js +44 -0
- package/dist/esm/tools/suppressConsole.js.map +1 -0
- package/package.json +87 -0
- package/src/harness/MockBackendVerifier.ts +14 -0
- package/src/harness/given.tsx +212 -0
- package/src/index.ts +44 -0
- package/src/mock-backend/BrowserStateCfg.ts +35 -0
- package/src/mock-backend/MockBackendConfigurer.ts +97 -0
- package/src/mock-backend/MockDb.ts +66 -0
- package/src/mock-backend/MockService.ts +18 -0
- package/src/mock-backend/MockUserContext.ts +42 -0
- package/src/mock-backend/magazines.ts +245 -0
- package/src/mock-network/NetworkCatalog.ts +30 -0
- package/src/mock-network/NetworkConfigurerCfg.ts +24 -0
- package/src/mock-network/SharedNetwork.ts +100 -0
- package/src/mock-network/errorFactories.ts +28 -0
- package/src/mock-network/makeNetworkReplyWithCatalog.ts +19 -0
- package/src/setup/polyfillLocalStorage.ts +34 -0
- package/src/setup/svgMock.tsx +3 -0
- package/src/setup/testSetup.ts +58 -0
- package/src/tools/AppDetailTools.ts +240 -0
- package/src/tools/BrowserState.ts +41 -0
- package/src/tools/CatalogTools.ts +254 -0
- package/src/tools/ErrorTools.ts +49 -0
- package/src/tools/GalleryTools.ts +26 -0
- 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,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
|
+
}
|