@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,58 @@
|
|
|
1
|
+
import "fake-indexeddb/auto";
|
|
2
|
+
import "./polyfillLocalStorage.js";
|
|
3
|
+
import { vi, afterEach } from "vitest";
|
|
4
|
+
import { cleanup } from "@testing-library/react";
|
|
5
|
+
import { cleanupTestResources } from "../harness/given.js";
|
|
6
|
+
import React from "react";
|
|
7
|
+
vi.mock("~/assets/app-catalog.svg?react", () => ({
|
|
8
|
+
default: (props) => React.createElement("svg", { "data-testid": "svg-mock", ...props })
|
|
9
|
+
}));
|
|
10
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
cleanup();
|
|
13
|
+
await cleanupTestResources();
|
|
14
|
+
localStorage.clear();
|
|
15
|
+
sessionStorage.clear();
|
|
16
|
+
});
|
|
17
|
+
Object.defineProperty(window, "matchMedia", {
|
|
18
|
+
writable: true,
|
|
19
|
+
value: (query) => ({
|
|
20
|
+
matches: false,
|
|
21
|
+
media: query,
|
|
22
|
+
onchange: null,
|
|
23
|
+
addListener: () => {
|
|
24
|
+
},
|
|
25
|
+
removeListener: () => {
|
|
26
|
+
},
|
|
27
|
+
addEventListener: () => {
|
|
28
|
+
},
|
|
29
|
+
removeEventListener: () => {
|
|
30
|
+
},
|
|
31
|
+
dispatchEvent: () => false
|
|
32
|
+
})
|
|
33
|
+
});
|
|
34
|
+
globalThis.IntersectionObserver = class IntersectionObserver {
|
|
35
|
+
constructor() {
|
|
36
|
+
this.root = null;
|
|
37
|
+
this.rootMargin = "";
|
|
38
|
+
this.thresholds = [];
|
|
39
|
+
}
|
|
40
|
+
observe() {
|
|
41
|
+
}
|
|
42
|
+
unobserve() {
|
|
43
|
+
}
|
|
44
|
+
disconnect() {
|
|
45
|
+
}
|
|
46
|
+
takeRecords() {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
51
|
+
observe() {
|
|
52
|
+
}
|
|
53
|
+
unobserve() {
|
|
54
|
+
}
|
|
55
|
+
disconnect() {
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
//# sourceMappingURL=testSetup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testSetup.js","sources":["../../../src/setup/testSetup.ts"],"sourcesContent":["import 'fake-indexeddb/auto'\nimport './polyfillLocalStorage'\nimport { afterEach, vi } from 'vitest'\nimport { cleanup } from '@testing-library/react'\nimport { cleanupTestResources } from '../harness/given'\nimport React from 'react'\n\n// Mock SVG React component imports (vite-plugin-svgr ?react suffix)\nvi.mock('~/assets/app-catalog.svg?react', () => ({\n default: (props: React.SVGProps<SVGSVGElement>) =>\n React.createElement('svg', { 'data-testid': 'svg-mock', ...props }),\n}))\n\n// Mock scrollIntoView — not implemented in jsdom\nElement.prototype.scrollIntoView = vi.fn()\n\n// Clean up after each test\nafterEach(async () => {\n cleanup()\n await cleanupTestResources()\n localStorage.clear()\n sessionStorage.clear()\n})\n\n// Mock window.matchMedia — required by embla-carousel in jsdom environment\nObject.defineProperty(window, 'matchMedia', {\n writable: true,\n value: (query: string) => ({\n matches: false,\n media: query,\n onchange: null,\n addListener: () => {},\n removeListener: () => {},\n addEventListener: () => {},\n removeEventListener: () => {},\n dispatchEvent: () => false,\n }),\n})\n\n// Mock IntersectionObserver — required by embla-carousel SlidesInView in jsdom\nglobalThis.IntersectionObserver = class IntersectionObserver {\n observe() {}\n unobserve() {}\n disconnect() {}\n takeRecords() {\n return []\n }\n readonly root = null\n readonly rootMargin = ''\n readonly thresholds = []\n} as unknown as typeof IntersectionObserver\n\n// Mock ResizeObserver — required by embla-carousel ResizeHandler in jsdom\nglobalThis.ResizeObserver = class ResizeObserver {\n observe() {}\n unobserve() {}\n disconnect() {}\n} as unknown as typeof ResizeObserver\n"],"names":[],"mappings":";;;;;;AAQA,GAAG,KAAK,kCAAkC,OAAO;AAAA,EAC/C,SAAS,CAAC,UACR,MAAM,cAAc,OAAO,EAAE,eAAe,YAAY,GAAG,MAAA,CAAO;AACtE,EAAE;AAGF,QAAQ,UAAU,iBAAiB,GAAG,GAAA;AAGtC,UAAU,YAAY;AACpB,UAAA;AACA,QAAM,qBAAA;AACN,eAAa,MAAA;AACb,iBAAe,MAAA;AACjB,CAAC;AAGD,OAAO,eAAe,QAAQ,cAAc;AAAA,EAC1C,UAAU;AAAA,EACV,OAAO,CAAC,WAAmB;AAAA,IACzB,SAAS;AAAA,IACT,OAAO;AAAA,IACP,UAAU;AAAA,IACV,aAAa,MAAM;AAAA,IAAC;AAAA,IACpB,gBAAgB,MAAM;AAAA,IAAC;AAAA,IACvB,kBAAkB,MAAM;AAAA,IAAC;AAAA,IACzB,qBAAqB,MAAM;AAAA,IAAC;AAAA,IAC5B,eAAe,MAAM;AAAA,EAAA;AAEzB,CAAC;AAGD,WAAW,uBAAuB,MAAM,qBAAqB;AAAA,EAA3B,cAAA;AAOhC,SAAS,OAAO;AAChB,SAAS,aAAa;AACtB,SAAS,aAAa,CAAA;AAAA,EAAC;AAAA,EARvB,UAAU;AAAA,EAAC;AAAA,EACX,YAAY;AAAA,EAAC;AAAA,EACb,aAAa;AAAA,EAAC;AAAA,EACd,cAAc;AACZ,WAAO,CAAA;AAAA,EACT;AAIF;AAGA,WAAW,iBAAiB,MAAM,eAAe;AAAA,EAC/C,UAAU;AAAA,EAAC;AAAA,EACX,YAAY;AAAA,EAAC;AAAA,EACb,aAAa;AAAA,EAAC;AAChB;"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface AppVisibleData {
|
|
2
|
+
title: string;
|
|
3
|
+
description: string | null;
|
|
4
|
+
url: string | null;
|
|
5
|
+
tags: string[];
|
|
6
|
+
screenshots: {
|
|
7
|
+
count: number;
|
|
8
|
+
};
|
|
9
|
+
deprecation: {
|
|
10
|
+
type: string;
|
|
11
|
+
comment: string;
|
|
12
|
+
} | null;
|
|
13
|
+
}
|
|
14
|
+
export declare class AppDetailTools {
|
|
15
|
+
private user;
|
|
16
|
+
/**
|
|
17
|
+
* Scrape all visible data from the app detail panel into a structured object.
|
|
18
|
+
*/
|
|
19
|
+
getVisibleData(): AppVisibleData;
|
|
20
|
+
/**
|
|
21
|
+
* Get the sub-resources section data from the detail panel.
|
|
22
|
+
* Returns null if no sub-resources section is visible.
|
|
23
|
+
*/
|
|
24
|
+
getSubResources(): {
|
|
25
|
+
total: number;
|
|
26
|
+
visible: number;
|
|
27
|
+
names: string[];
|
|
28
|
+
} | null;
|
|
29
|
+
/**
|
|
30
|
+
* Read the "How to get access" section text from the detail panel.
|
|
31
|
+
* Returns null if the section is not present.
|
|
32
|
+
*/
|
|
33
|
+
getAccessText(): string | null;
|
|
34
|
+
screenshots: {
|
|
35
|
+
/**
|
|
36
|
+
* Click the screenshot preview to open the gallery modal.
|
|
37
|
+
*/
|
|
38
|
+
open: () => Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
/** Click the "View replacement: <App>" link in a deprecated app's panel. */
|
|
41
|
+
clickViewReplacement(): Promise<void>;
|
|
42
|
+
/** The title shown in the currently open detail panel (empty if none). */
|
|
43
|
+
getOpenTitle(): string;
|
|
44
|
+
/**
|
|
45
|
+
* The prominent primary "Open" button in the app detail card.
|
|
46
|
+
* Returns the anchor element when the app has an appUrl, null otherwise.
|
|
47
|
+
*/
|
|
48
|
+
getOpenButton(): HTMLAnchorElement | null;
|
|
49
|
+
/**
|
|
50
|
+
* Get sub-resource detail panel data when a sub-resource is selected.
|
|
51
|
+
* Returns null if the sub-resource detail panel is not shown.
|
|
52
|
+
*/
|
|
53
|
+
getSubResourceDetail(): {
|
|
54
|
+
subResourceName: string;
|
|
55
|
+
hasStep1: boolean;
|
|
56
|
+
hasStep2: boolean;
|
|
57
|
+
backButtonLabel: string | null;
|
|
58
|
+
} | null;
|
|
59
|
+
clickBackToParent(): Promise<void>;
|
|
60
|
+
private getPanel;
|
|
61
|
+
private findHeading;
|
|
62
|
+
private getNextSiblingText;
|
|
63
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
class AppDetailTools {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.user = userEvent.setup();
|
|
6
|
+
this.screenshots = {
|
|
7
|
+
/**
|
|
8
|
+
* Click the screenshot preview to open the gallery modal.
|
|
9
|
+
*/
|
|
10
|
+
open: async () => {
|
|
11
|
+
const panel = this.getPanel();
|
|
12
|
+
const screenshotArea = panel.querySelector(".cursor-pointer");
|
|
13
|
+
if (!screenshotArea) {
|
|
14
|
+
throw new Error("No clickable screenshot found in detail panel");
|
|
15
|
+
}
|
|
16
|
+
await this.user.click(screenshotArea);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Scrape all visible data from the app detail panel into a structured object.
|
|
22
|
+
*/
|
|
23
|
+
getVisibleData() {
|
|
24
|
+
const panel = this.getPanel();
|
|
25
|
+
const titleEl = panel.querySelector(".text-2xl");
|
|
26
|
+
const title = (titleEl == null ? void 0 : titleEl.textContent.trim()) ?? "";
|
|
27
|
+
const descHeading = this.findHeading(panel, "Description");
|
|
28
|
+
const description = descHeading ? this.getNextSiblingText(descHeading) : null;
|
|
29
|
+
const urlLink = panel.querySelector('a[href][target="_blank"]');
|
|
30
|
+
const url = (urlLink == null ? void 0 : urlLink.getAttribute("href")) ?? null;
|
|
31
|
+
const tagsHeading = this.findHeading(panel, "Tags");
|
|
32
|
+
const tags = [];
|
|
33
|
+
if (tagsHeading) {
|
|
34
|
+
const container = tagsHeading.nextElementSibling;
|
|
35
|
+
if (container) {
|
|
36
|
+
container.querySelectorAll('[class*="badge"], [data-slot="badge"]').forEach((badge) => {
|
|
37
|
+
const text = badge.textContent.trim();
|
|
38
|
+
if (text) tags.push(text);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const screenshotHeading = this.findHeading(panel, "Screenshots");
|
|
43
|
+
let screenshotCount = 0;
|
|
44
|
+
if (screenshotHeading) {
|
|
45
|
+
const match = screenshotHeading.textContent.match(/\((\d+)\)/);
|
|
46
|
+
screenshotCount = (match == null ? void 0 : match[1]) ? parseInt(match[1], 10) : 0;
|
|
47
|
+
}
|
|
48
|
+
let deprecation = null;
|
|
49
|
+
const deprecationEl = panel.querySelector(
|
|
50
|
+
'[class*="border-destructive"], [class*="border-yellow"]'
|
|
51
|
+
);
|
|
52
|
+
if (deprecationEl) {
|
|
53
|
+
const typeEl = deprecationEl.querySelector("h3");
|
|
54
|
+
const commentEl = deprecationEl.querySelector("p");
|
|
55
|
+
deprecation = {
|
|
56
|
+
type: (typeEl == null ? void 0 : typeEl.textContent.toLowerCase().includes("discouraged")) ? "discouraged" : "deprecated",
|
|
57
|
+
comment: (commentEl == null ? void 0 : commentEl.textContent.trim()) ?? ""
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
title,
|
|
62
|
+
description,
|
|
63
|
+
url,
|
|
64
|
+
tags,
|
|
65
|
+
screenshots: { count: screenshotCount },
|
|
66
|
+
deprecation
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get the sub-resources section data from the detail panel.
|
|
71
|
+
* Returns null if no sub-resources section is visible.
|
|
72
|
+
*/
|
|
73
|
+
getSubResources() {
|
|
74
|
+
const panel = this.getPanel();
|
|
75
|
+
const heading = Array.from(panel.querySelectorAll("div")).find(
|
|
76
|
+
(el) => el.textContent.match(/Sub-Resources \(\d+ of \d+\)/)
|
|
77
|
+
);
|
|
78
|
+
if (!heading) return null;
|
|
79
|
+
const match = heading.textContent.match(/Sub-Resources \((\d+) of (\d+)\)/);
|
|
80
|
+
const visible = (match == null ? void 0 : match[1]) ? parseInt(match[1], 10) : 0;
|
|
81
|
+
const total = (match == null ? void 0 : match[2]) ? parseInt(match[2], 10) : 0;
|
|
82
|
+
const names = [];
|
|
83
|
+
const rows = panel.querySelectorAll("table tbody tr");
|
|
84
|
+
rows.forEach((row) => {
|
|
85
|
+
const nameCell = row.querySelector("td .font-medium");
|
|
86
|
+
if (nameCell == null ? void 0 : nameCell.textContent) {
|
|
87
|
+
names.push(nameCell.textContent.trim());
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return { total, visible, names };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read the "How to get access" section text from the detail panel.
|
|
94
|
+
* Returns null if the section is not present.
|
|
95
|
+
*/
|
|
96
|
+
getAccessText() {
|
|
97
|
+
const panel = this.getPanel();
|
|
98
|
+
const heading = this.findHeading(panel, "How to get access");
|
|
99
|
+
if (!heading) return null;
|
|
100
|
+
const container = heading.parentElement ?? heading;
|
|
101
|
+
return container.textContent.trim();
|
|
102
|
+
}
|
|
103
|
+
/** Click the "View replacement: <App>" link in a deprecated app's panel. */
|
|
104
|
+
async clickViewReplacement() {
|
|
105
|
+
const btn = screen.getByRole("button", { name: /View replacement:/i });
|
|
106
|
+
await this.user.click(btn);
|
|
107
|
+
}
|
|
108
|
+
/** The title shown in the currently open detail panel (empty if none). */
|
|
109
|
+
getOpenTitle() {
|
|
110
|
+
var _a;
|
|
111
|
+
const panel = this.getPanel();
|
|
112
|
+
return ((_a = panel.querySelector(".text-2xl")) == null ? void 0 : _a.textContent.trim()) ?? "";
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The prominent primary "Open" button in the app detail card.
|
|
116
|
+
* Returns the anchor element when the app has an appUrl, null otherwise.
|
|
117
|
+
*/
|
|
118
|
+
getOpenButton() {
|
|
119
|
+
const panel = this.getPanel();
|
|
120
|
+
return panel.querySelector('[aria-label^="Open "]') ?? null;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get sub-resource detail panel data when a sub-resource is selected.
|
|
124
|
+
* Returns null if the sub-resource detail panel is not shown.
|
|
125
|
+
*/
|
|
126
|
+
getSubResourceDetail() {
|
|
127
|
+
const panel = document.querySelector(
|
|
128
|
+
'[aria-label^="Back to "]'
|
|
129
|
+
);
|
|
130
|
+
if (!panel) return null;
|
|
131
|
+
const backButtonEl = document.querySelector(
|
|
132
|
+
'[aria-label^="Back to "]'
|
|
133
|
+
);
|
|
134
|
+
const backButtonLabel = (backButtonEl == null ? void 0 : backButtonEl.getAttribute("aria-label")) ?? null;
|
|
135
|
+
const h2 = document.querySelector("h2.text-xl");
|
|
136
|
+
const subResourceName = ((h2 == null ? void 0 : h2.textContent) ?? "").trim();
|
|
137
|
+
const stepBadges = Array.from(
|
|
138
|
+
document.querySelectorAll("span.rounded-full")
|
|
139
|
+
);
|
|
140
|
+
const getText = (el) => (el.textContent ?? "").trim();
|
|
141
|
+
const hasStep1 = stepBadges.some((el) => getText(el) === "1");
|
|
142
|
+
const hasStep2 = stepBadges.some((el) => getText(el) === "2");
|
|
143
|
+
return { subResourceName, hasStep1, hasStep2, backButtonLabel };
|
|
144
|
+
}
|
|
145
|
+
async clickBackToParent() {
|
|
146
|
+
const backButton = document.querySelector(
|
|
147
|
+
'[aria-label^="Back to "]'
|
|
148
|
+
);
|
|
149
|
+
if (!backButton) {
|
|
150
|
+
throw new Error("Back-to-parent button not found in detail panel");
|
|
151
|
+
}
|
|
152
|
+
await this.user.click(backButton);
|
|
153
|
+
}
|
|
154
|
+
getPanel() {
|
|
155
|
+
const closeButton = screen.queryByLabelText("Close details panel");
|
|
156
|
+
if (!closeButton) {
|
|
157
|
+
throw new Error("App detail panel is not open");
|
|
158
|
+
}
|
|
159
|
+
const panel = closeButton.closest('[class*="overflow-y-auto"]');
|
|
160
|
+
if (!panel) {
|
|
161
|
+
throw new Error("Could not find detail panel container");
|
|
162
|
+
}
|
|
163
|
+
return panel;
|
|
164
|
+
}
|
|
165
|
+
findHeading(container, text) {
|
|
166
|
+
const headings = container.querySelectorAll("h3");
|
|
167
|
+
for (const h of headings) {
|
|
168
|
+
if (h.textContent.includes(text)) return h;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
getNextSiblingText(heading) {
|
|
173
|
+
const sibling = heading.nextElementSibling;
|
|
174
|
+
return (sibling == null ? void 0 : sibling.textContent.trim()) ?? null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
AppDetailTools
|
|
179
|
+
};
|
|
180
|
+
//# sourceMappingURL=AppDetailTools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AppDetailTools.js","sources":["../../../src/tools/AppDetailTools.ts"],"sourcesContent":["import { screen } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\n\nexport interface AppVisibleData {\n title: string\n description: string | null\n url: string | null\n tags: string[]\n screenshots: { count: number }\n deprecation: { type: string; comment: string } | null\n}\n\nexport class AppDetailTools {\n private user = userEvent.setup()\n\n /**\n * Scrape all visible data from the app detail panel into a structured object.\n */\n getVisibleData(): AppVisibleData {\n const panel = this.getPanel()\n\n // Title: the large text in the header area\n const titleEl = panel.querySelector('.text-2xl')\n const title = titleEl?.textContent.trim() ?? ''\n\n // Description\n const descHeading = this.findHeading(panel, 'Description')\n const description = descHeading\n ? this.getNextSiblingText(descHeading)\n : null\n\n // URL\n const urlLink = panel.querySelector('a[href][target=\"_blank\"]')\n const url = urlLink?.getAttribute('href') ?? null\n\n // Tags\n const tagsHeading = this.findHeading(panel, 'Tags')\n const tags: string[] = []\n if (tagsHeading) {\n const container = tagsHeading.nextElementSibling\n if (container) {\n container\n .querySelectorAll('[class*=\"badge\"], [data-slot=\"badge\"]')\n .forEach((badge) => {\n const text = badge.textContent.trim()\n if (text) tags.push(text)\n })\n }\n }\n\n // Screenshots\n const screenshotHeading = this.findHeading(panel, 'Screenshots')\n let screenshotCount = 0\n if (screenshotHeading) {\n const match = screenshotHeading.textContent.match(/\\((\\d+)\\)/)\n screenshotCount = match?.[1] ? parseInt(match[1], 10) : 0\n }\n\n // Deprecation\n let deprecation: AppVisibleData['deprecation'] = null\n const deprecationEl = panel.querySelector(\n '[class*=\"border-destructive\"], [class*=\"border-yellow\"]',\n )\n if (deprecationEl) {\n const typeEl = deprecationEl.querySelector('h3')\n const commentEl = deprecationEl.querySelector('p')\n deprecation = {\n type: typeEl?.textContent.toLowerCase().includes('discouraged')\n ? 'discouraged'\n : 'deprecated',\n comment: commentEl?.textContent.trim() ?? '',\n }\n }\n\n return {\n title,\n description,\n url,\n tags,\n screenshots: { count: screenshotCount },\n deprecation,\n }\n }\n\n /**\n * Get the sub-resources section data from the detail panel.\n * Returns null if no sub-resources section is visible.\n */\n getSubResources(): {\n total: number\n visible: number\n names: string[]\n } | null {\n const panel = this.getPanel()\n const heading = Array.from(panel.querySelectorAll('div')).find((el) =>\n el.textContent.match(/Sub-Resources \\(\\d+ of \\d+\\)/),\n )\n if (!heading) return null\n\n const match = heading.textContent.match(/Sub-Resources \\((\\d+) of (\\d+)\\)/)\n const visible = match?.[1] ? parseInt(match[1], 10) : 0\n const total = match?.[2] ? parseInt(match[2], 10) : 0\n\n const names: string[] = []\n const rows = panel.querySelectorAll('table tbody tr')\n rows.forEach((row) => {\n const nameCell = row.querySelector('td .font-medium')\n if (nameCell?.textContent) {\n names.push(nameCell.textContent.trim())\n }\n })\n\n return { total, visible, names }\n }\n\n /**\n * Read the \"How to get access\" section text from the detail panel.\n * Returns null if the section is not present.\n */\n getAccessText(): string | null {\n const panel = this.getPanel()\n const heading = this.findHeading(panel, 'How to get access')\n if (!heading) return null\n // The section is the heading plus its following content wrapper.\n const container = heading.parentElement ?? heading\n return container.textContent.trim()\n }\n\n screenshots = {\n /**\n * Click the screenshot preview to open the gallery modal.\n */\n open: async (): Promise<void> => {\n const panel = this.getPanel()\n const screenshotArea = panel.querySelector('.cursor-pointer')\n if (!screenshotArea) {\n throw new Error('No clickable screenshot found in detail panel')\n }\n await this.user.click(screenshotArea)\n },\n }\n\n /** Click the \"View replacement: <App>\" link in a deprecated app's panel. */\n async clickViewReplacement(): Promise<void> {\n const btn = screen.getByRole('button', { name: /View replacement:/i })\n await this.user.click(btn)\n }\n\n /** The title shown in the currently open detail panel (empty if none). */\n getOpenTitle(): string {\n const panel = this.getPanel()\n return panel.querySelector('.text-2xl')?.textContent.trim() ?? ''\n }\n\n /**\n * The prominent primary \"Open\" button in the app detail card.\n * Returns the anchor element when the app has an appUrl, null otherwise.\n */\n getOpenButton(): HTMLAnchorElement | null {\n const panel = this.getPanel()\n return (\n panel.querySelector<HTMLAnchorElement>('[aria-label^=\"Open \"]') ?? null\n )\n }\n\n /**\n * Get sub-resource detail panel data when a sub-resource is selected.\n * Returns null if the sub-resource detail panel is not shown.\n */\n getSubResourceDetail(): {\n subResourceName: string\n hasStep1: boolean\n hasStep2: boolean\n backButtonLabel: string | null\n } | null {\n const panel = document.querySelector<HTMLElement>(\n '[aria-label^=\"Back to \"]',\n )\n if (!panel) return null\n\n const backButtonEl = document.querySelector<HTMLElement>(\n '[aria-label^=\"Back to \"]',\n )\n const backButtonLabel = backButtonEl?.getAttribute('aria-label') ?? null\n\n // Sub-resource name: h2 in the detail panel area\n const h2 = document.querySelector('h2.text-xl')\n const subResourceName = (h2?.textContent ?? '').trim()\n\n // Step badges: circles with \"1\" and \"2\" text\n const stepBadges = Array.from(\n document.querySelectorAll<HTMLElement>('span.rounded-full'),\n )\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n const getText = (el: HTMLElement) => (el.textContent ?? '').trim()\n const hasStep1 = stepBadges.some((el) => getText(el) === '1')\n const hasStep2 = stepBadges.some((el) => getText(el) === '2')\n\n return { subResourceName, hasStep1, hasStep2, backButtonLabel }\n }\n\n async clickBackToParent(): Promise<void> {\n const backButton = document.querySelector<HTMLButtonElement>(\n '[aria-label^=\"Back to \"]',\n )\n if (!backButton) {\n throw new Error('Back-to-parent button not found in detail panel')\n }\n await this.user.click(backButton)\n }\n\n private getPanel(): HTMLElement {\n const closeButton = screen.queryByLabelText('Close details panel')\n if (!closeButton) {\n throw new Error('App detail panel is not open')\n }\n // The panel is the closest scrollable container\n const panel = closeButton.closest('[class*=\"overflow-y-auto\"]')\n if (!panel) {\n throw new Error('Could not find detail panel container')\n }\n return panel as HTMLElement\n }\n\n private findHeading(\n container: HTMLElement,\n text: string,\n ): HTMLElement | null {\n const headings = container.querySelectorAll('h3')\n for (const h of headings) {\n if (h.textContent.includes(text)) return h as HTMLElement\n }\n return null\n }\n\n private getNextSiblingText(heading: HTMLElement): string | null {\n const sibling = heading.nextElementSibling\n return sibling?.textContent.trim() ?? null\n }\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AppCatalogData } from '@igstack/app-catalog-backend-core';
|
|
2
|
+
/**
|
|
3
|
+
* Pre-populate the browser's local state to simulate a returning user.
|
|
4
|
+
* Call before `given()` to set up IndexedDB cache and localStorage flags.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* await browserState.seedCatalogCache(data)
|
|
8
|
+
* browserState.dismissOnboarding()
|
|
9
|
+
*/
|
|
10
|
+
export declare const browserState: {
|
|
11
|
+
/**
|
|
12
|
+
* Seed the IndexedDB cache with app catalog data,
|
|
13
|
+
* as if the user has previously loaded the app.
|
|
14
|
+
*/
|
|
15
|
+
seedCatalogCache(data: AppCatalogData): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Mark the onboarding card as dismissed in localStorage,
|
|
18
|
+
* as if the user has already seen and closed it.
|
|
19
|
+
*/
|
|
20
|
+
dismissOnboarding(): void;
|
|
21
|
+
/**
|
|
22
|
+
* Simulate a returning user: onboarding dismissed + catalog data cached.
|
|
23
|
+
*/
|
|
24
|
+
returningUser(data: AppCatalogData): Promise<void>;
|
|
25
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AcDb, dbCacheDbKeys } from "@igstack/app-catalog-frontend-core/internal";
|
|
2
|
+
const browserState = {
|
|
3
|
+
/**
|
|
4
|
+
* Seed the IndexedDB cache with app catalog data,
|
|
5
|
+
* as if the user has previously loaded the app.
|
|
6
|
+
*/
|
|
7
|
+
async seedCatalogCache(data) {
|
|
8
|
+
const db = new AcDb();
|
|
9
|
+
await db.appCatalog.put(data, dbCacheDbKeys.AppCatalog);
|
|
10
|
+
db.close();
|
|
11
|
+
},
|
|
12
|
+
/**
|
|
13
|
+
* Mark the onboarding card as dismissed in localStorage,
|
|
14
|
+
* as if the user has already seen and closed it.
|
|
15
|
+
*/
|
|
16
|
+
dismissOnboarding() {
|
|
17
|
+
localStorage.setItem("app-catalog-onboarding-dismissed", "true");
|
|
18
|
+
},
|
|
19
|
+
/**
|
|
20
|
+
* Simulate a returning user: onboarding dismissed + catalog data cached.
|
|
21
|
+
*/
|
|
22
|
+
async returningUser(data) {
|
|
23
|
+
this.dismissOnboarding();
|
|
24
|
+
await this.seedCatalogCache(data);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
export {
|
|
28
|
+
browserState
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=BrowserState.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BrowserState.js","sources":["../../../src/tools/BrowserState.ts"],"sourcesContent":["import type { AppCatalogData } from '@igstack/app-catalog-backend-core'\nimport {\n AcDb,\n dbCacheDbKeys,\n} from '@igstack/app-catalog-frontend-core/internal'\n\n/**\n * Pre-populate the browser's local state to simulate a returning user.\n * Call before `given()` to set up IndexedDB cache and localStorage flags.\n *\n * Usage:\n * await browserState.seedCatalogCache(data)\n * browserState.dismissOnboarding()\n */\nexport const browserState = {\n /**\n * Seed the IndexedDB cache with app catalog data,\n * as if the user has previously loaded the app.\n */\n async seedCatalogCache(data: AppCatalogData): Promise<void> {\n const db = new AcDb()\n await db.appCatalog.put(data, dbCacheDbKeys.AppCatalog)\n db.close()\n },\n\n /**\n * Mark the onboarding card as dismissed in localStorage,\n * as if the user has already seen and closed it.\n */\n dismissOnboarding(): void {\n localStorage.setItem('app-catalog-onboarding-dismissed', 'true')\n },\n\n /**\n * Simulate a returning user: onboarding dismissed + catalog data cached.\n */\n async returningUser(data: AppCatalogData): Promise<void> {\n this.dismissOnboarding()\n await this.seedCatalogCache(data)\n },\n}\n"],"names":[],"mappings":";AAcO,MAAM,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA,EAK1B,MAAM,iBAAiB,MAAqC;AAC1D,UAAM,KAAK,IAAI,KAAA;AACf,UAAM,GAAG,WAAW,IAAI,MAAM,cAAc,UAAU;AACtD,OAAG,MAAA;AAAA,EACL;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,oBAA0B;AACxB,iBAAa,QAAQ,oCAAoC,MAAM;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,cAAc,MAAqC;AACvD,SAAK,kBAAA;AACL,UAAM,KAAK,iBAAiB,IAAI;AAAA,EAClC;AACF;"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface TableRow {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
}
|
|
5
|
+
export declare class CatalogTools {
|
|
6
|
+
private user;
|
|
7
|
+
/**
|
|
8
|
+
* Click an app row by display name.
|
|
9
|
+
* Works against both the table/grid view and the launcher home (#38), which
|
|
10
|
+
* renders app rows as buttons rather than a <table>.
|
|
11
|
+
* Throws with list of visible apps if name not found.
|
|
12
|
+
*/
|
|
13
|
+
openApp(name: string): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Type into the search input.
|
|
16
|
+
*/
|
|
17
|
+
search(text: string): Promise<void>;
|
|
18
|
+
/** The catalog search input. */
|
|
19
|
+
getSearchInput(): HTMLInputElement;
|
|
20
|
+
/**
|
|
21
|
+
* Parse the catalog table into structured data.
|
|
22
|
+
* Skips group header rows (colspan rows).
|
|
23
|
+
*/
|
|
24
|
+
getTableData(): TableRow[];
|
|
25
|
+
/**
|
|
26
|
+
* The secondary "open in new tab" launch link for a row, by app name.
|
|
27
|
+
* Returns null if the row has no launch link.
|
|
28
|
+
*/
|
|
29
|
+
getLaunchLink(name: string): HTMLAnchorElement | null;
|
|
30
|
+
/**
|
|
31
|
+
* Whether the right detail panel is currently visible.
|
|
32
|
+
*/
|
|
33
|
+
isDetailPanelOpen(): boolean;
|
|
34
|
+
/** Whether the "Show Deprecated Apps" toggle is currently checked. */
|
|
35
|
+
isShowDeprecatedChecked(): boolean;
|
|
36
|
+
/** Whether the "showing deprecated matches" fallback notice is visible. */
|
|
37
|
+
hasDeprecatedFallbackNotice(): boolean;
|
|
38
|
+
/** Whether the "No apps found" empty state or search-morph "No results for" is visible. */
|
|
39
|
+
isEmptyStateVisible(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether the onboarding/welcome card is visible.
|
|
42
|
+
*/
|
|
43
|
+
isOnboardingVisible(): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Read app rows from the launcher home (#38): buttons titled "View <name>".
|
|
46
|
+
* Name is the first `.font-semibold`/`.font-bold` span; description the
|
|
47
|
+
* `.text-muted-foreground` span. Deduped by name (an app can appear both in
|
|
48
|
+
* "Your apps" and "Browse all").
|
|
49
|
+
*/
|
|
50
|
+
private getLauncherRows;
|
|
51
|
+
/**
|
|
52
|
+
* Get expandable subresource rows shown under parents in search results.
|
|
53
|
+
* Returns null if no sub-rows are visible.
|
|
54
|
+
*/
|
|
55
|
+
getSubResourceRows(): {
|
|
56
|
+
visible: number;
|
|
57
|
+
total: number;
|
|
58
|
+
hasExpandRow: boolean;
|
|
59
|
+
names: string[];
|
|
60
|
+
} | null;
|
|
61
|
+
clickSubResource(displayName: string): Promise<void>;
|
|
62
|
+
expandSubResources(): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Get the main catalog table (first table on page, skipping sub-resource tables in detail panel).
|
|
65
|
+
*/
|
|
66
|
+
private getCatalogTable;
|
|
67
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { within, screen, fireEvent } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
class CatalogTools {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.user = userEvent.setup();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Click an app row by display name.
|
|
9
|
+
* Works against both the table/grid view and the launcher home (#38), which
|
|
10
|
+
* renders app rows as buttons rather than a <table>.
|
|
11
|
+
* Throws with list of visible apps if name not found.
|
|
12
|
+
*/
|
|
13
|
+
async openApp(name) {
|
|
14
|
+
const table = this.getCatalogTable();
|
|
15
|
+
if (table) {
|
|
16
|
+
const rows = within(table).getAllByRole("row");
|
|
17
|
+
for (const row of rows) {
|
|
18
|
+
const nameEl = row.querySelector(".font-medium");
|
|
19
|
+
if ((nameEl == null ? void 0 : nameEl.textContent.trim()) === name) {
|
|
20
|
+
await this.user.click(row);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
const btn = screen.queryByTitle(`View ${name}`);
|
|
26
|
+
if (btn) {
|
|
27
|
+
await this.user.click(btn);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const visibleNames = this.getTableData().map((r) => r.name);
|
|
32
|
+
throw new Error(
|
|
33
|
+
`App "${name}" not found. Visible apps: [${visibleNames.join(", ")}]`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Type into the search input.
|
|
38
|
+
*/
|
|
39
|
+
async search(text) {
|
|
40
|
+
const input = screen.getByLabelText("Search apps");
|
|
41
|
+
fireEvent.change(input, { target: { value: text } });
|
|
42
|
+
}
|
|
43
|
+
/** The catalog search input. */
|
|
44
|
+
getSearchInput() {
|
|
45
|
+
return screen.getByLabelText("Search apps");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse the catalog table into structured data.
|
|
49
|
+
* Skips group header rows (colspan rows).
|
|
50
|
+
*/
|
|
51
|
+
getTableData() {
|
|
52
|
+
var _a, _b;
|
|
53
|
+
const table = this.getCatalogTable();
|
|
54
|
+
if (!table) {
|
|
55
|
+
const bodyText = document.body.textContent;
|
|
56
|
+
if (bodyText.includes("Something went wrong") || bodyText.includes("Ooops")) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Cannot read table — global error on page: ${bodyText.slice(0, 500)}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const launcherRows = this.getLauncherRows();
|
|
62
|
+
if (launcherRows.length > 0) return launcherRows;
|
|
63
|
+
throw new Error("No table or launcher rows found on page");
|
|
64
|
+
}
|
|
65
|
+
const rows = within(table).getAllByRole("row");
|
|
66
|
+
const result = [];
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
const cells = within(row).queryAllByRole("cell");
|
|
69
|
+
if (cells.length < 2) continue;
|
|
70
|
+
const nameEl = (_a = cells[0]) == null ? void 0 : _a.querySelector(".font-medium");
|
|
71
|
+
if (!nameEl) continue;
|
|
72
|
+
result.push({
|
|
73
|
+
name: nameEl.textContent.trim(),
|
|
74
|
+
description: ((_b = cells[1]) == null ? void 0 : _b.textContent.trim()) ?? ""
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The secondary "open in new tab" launch link for a row, by app name.
|
|
81
|
+
* Returns null if the row has no launch link.
|
|
82
|
+
*/
|
|
83
|
+
getLaunchLink(name) {
|
|
84
|
+
return screen.queryByLabelText(
|
|
85
|
+
new RegExp(`^Open ${name} in a new tab`)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether the right detail panel is currently visible.
|
|
90
|
+
*/
|
|
91
|
+
isDetailPanelOpen() {
|
|
92
|
+
return !!screen.queryByLabelText("Close details panel");
|
|
93
|
+
}
|
|
94
|
+
/** Whether the "Show Deprecated Apps" toggle is currently checked. */
|
|
95
|
+
isShowDeprecatedChecked() {
|
|
96
|
+
const cb = screen.getByRole("checkbox", { name: /Show Deprecated Apps/i });
|
|
97
|
+
return cb.getAttribute("aria-checked") === "true" || cb.getAttribute("data-state") === "checked";
|
|
98
|
+
}
|
|
99
|
+
/** Whether the "showing deprecated matches" fallback notice is visible. */
|
|
100
|
+
hasDeprecatedFallbackNotice() {
|
|
101
|
+
return !!screen.queryByText(/showing deprecated matches/i);
|
|
102
|
+
}
|
|
103
|
+
/** Whether the "No apps found" empty state or search-morph "No results for" is visible. */
|
|
104
|
+
isEmptyStateVisible() {
|
|
105
|
+
return !!screen.queryByText(/No apps found/i) || !!screen.queryByText(/No results for/i);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether the onboarding/welcome card is visible.
|
|
109
|
+
*/
|
|
110
|
+
isOnboardingVisible() {
|
|
111
|
+
return !!screen.queryByText("Welcome to App Catalog");
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Read app rows from the launcher home (#38): buttons titled "View <name>".
|
|
115
|
+
* Name is the first `.font-semibold`/`.font-bold` span; description the
|
|
116
|
+
* `.text-muted-foreground` span. Deduped by name (an app can appear both in
|
|
117
|
+
* "Your apps" and "Browse all").
|
|
118
|
+
*/
|
|
119
|
+
getLauncherRows() {
|
|
120
|
+
const buttons = Array.from(
|
|
121
|
+
document.querySelectorAll('button[title^="View "]')
|
|
122
|
+
);
|
|
123
|
+
const seen = /* @__PURE__ */ new Set();
|
|
124
|
+
const result = [];
|
|
125
|
+
for (const btn of buttons) {
|
|
126
|
+
const name = (btn.getAttribute("title") ?? "").replace(/^View\s+/, "").trim();
|
|
127
|
+
if (!name || seen.has(name)) continue;
|
|
128
|
+
seen.add(name);
|
|
129
|
+
const descEl = btn.querySelector(".text-muted-foreground");
|
|
130
|
+
result.push({ name, description: (descEl == null ? void 0 : descEl.textContent.trim()) ?? "" });
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get expandable subresource rows shown under parents in search results.
|
|
136
|
+
* Returns null if no sub-rows are visible.
|
|
137
|
+
*/
|
|
138
|
+
getSubResourceRows() {
|
|
139
|
+
const subButtons = Array.from(
|
|
140
|
+
document.querySelectorAll(
|
|
141
|
+
'button[class*="border-l-2"]'
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
if (subButtons.length === 0) return null;
|
|
145
|
+
const getText = (el) => (el.textContent ?? "").trim();
|
|
146
|
+
const isExpandRow = (el) => getText(el).startsWith("...");
|
|
147
|
+
const expandButton = subButtons.find(isExpandRow);
|
|
148
|
+
const hasExpandRow = !!expandButton;
|
|
149
|
+
let hiddenCount = 0;
|
|
150
|
+
if (expandButton) {
|
|
151
|
+
const match = /\d+/.exec(getText(expandButton));
|
|
152
|
+
hiddenCount = (match == null ? void 0 : match[0]) ? parseInt(match[0], 10) : 0;
|
|
153
|
+
}
|
|
154
|
+
const visibleSubs = subButtons.filter((b) => !isExpandRow(b));
|
|
155
|
+
return {
|
|
156
|
+
visible: visibleSubs.length,
|
|
157
|
+
total: visibleSubs.length + hiddenCount,
|
|
158
|
+
hasExpandRow,
|
|
159
|
+
names: visibleSubs.map((b) => getText(b))
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async clickSubResource(displayName) {
|
|
163
|
+
const subButtons = Array.from(
|
|
164
|
+
document.querySelectorAll(
|
|
165
|
+
'button[class*="border-l-2"]'
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
const getText = (el) => (el.textContent ?? "").trim();
|
|
169
|
+
const target = subButtons.find(
|
|
170
|
+
(b) => getText(b) === displayName && !getText(b).trimStart().startsWith("...")
|
|
171
|
+
);
|
|
172
|
+
if (!target) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Sub-resource "${displayName}" not found in search result rows`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
await this.user.click(target);
|
|
178
|
+
}
|
|
179
|
+
async expandSubResources() {
|
|
180
|
+
const getText = (el) => (el.textContent ?? "").trim();
|
|
181
|
+
const expandButtons = Array.from(
|
|
182
|
+
document.querySelectorAll(
|
|
183
|
+
'button[class*="border-l-2"]'
|
|
184
|
+
)
|
|
185
|
+
).filter((b) => getText(b).trimStart().startsWith("..."));
|
|
186
|
+
if (expandButtons.length === 0) {
|
|
187
|
+
throw new Error('No expand ("...N more") button found');
|
|
188
|
+
}
|
|
189
|
+
await this.user.click(expandButtons[0]);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Get the main catalog table (first table on page, skipping sub-resource tables in detail panel).
|
|
193
|
+
*/
|
|
194
|
+
getCatalogTable() {
|
|
195
|
+
const tables = screen.queryAllByRole("table");
|
|
196
|
+
return tables[0] ?? null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export {
|
|
200
|
+
CatalogTools
|
|
201
|
+
};
|
|
202
|
+
//# sourceMappingURL=CatalogTools.js.map
|