@salesforcedevs/sfdocs-e2e 1.0.12-fix-ver-picker-1 → 1.0.12-fix-redoc-topic-v-2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforcedevs/sfdocs-e2e",
3
- "version": "1.0.12-fix-ver-picker-1",
3
+ "version": "1.0.12-fix-redoc-topic-v-2",
4
4
  "description": "E2E test suite for SFDocs static portal pages (SSG)",
5
5
  "license": "ISC",
6
6
  "bin": {
@@ -149,17 +149,9 @@ describe('Component Specification', () => {
149
149
  'div.container div.sidebar-content dx-tree',
150
150
  );
151
151
 
152
- const getItemText = async (item: WebdriverIO.Element) => {
153
- const link = await item.shadow$('a');
154
- const tiles = await link.$$('dx-tree-tile');
155
- const tileLink = await tiles[0].shadow$('a.tile.sidebar-item');
156
- const label = await tileLink.$('span.tile-label');
157
- return (await label.getText()).trim();
158
- };
159
-
160
152
  const firstItem = (await sidebarTrees[0].shadow$$('dx-tree-item'))[0];
161
153
  const firstChildItem = (await firstItem.shadow$$('dx-tree-item'))[0];
162
- const firstChildText = await getItemText(firstChildItem);
154
+ const firstChildText = await page.getTreeItemLabel(firstChildItem);
163
155
 
164
156
  expect(firstChildText).toBe('Accordion');
165
157
  });
@@ -264,18 +256,10 @@ describe('Component Specification', () => {
264
256
  'div.container div.sidebar-content dx-tree',
265
257
  );
266
258
 
267
- const getItemText = async (item: WebdriverIO.Element) => {
268
- const link = await item.shadow$('a');
269
- const tiles = await link.$$('dx-tree-tile');
270
- const tileLink = await tiles[0].shadow$('a.tile.sidebar-item');
271
- const label = await tileLink.$('span.tile-label');
272
- return (await label.getText()).trim();
273
- };
274
-
275
259
  const auraItem = (await sidebarTrees[1].shadow$$('dx-tree-item'))[0];
276
260
  const lightningItem = (await auraItem.shadow$$('dx-tree-item'))[0];
277
261
  const componentsItem = (await lightningItem.shadow$$('dx-tree-item'))[0];
278
- const firstComponentText = await getItemText(
262
+ const firstComponentText = await page.getTreeItemLabel(
279
263
  (
280
264
  await componentsItem.shadow$$('dx-tree-item')
281
265
  )[0],
@@ -56,7 +56,9 @@ describe('Test Sitemap Routes', () => {
56
56
  );
57
57
 
58
58
  // Spec (Redoc) topics are published without an extension.
59
- expect(data.body).toContain('<loc>/docs/docs-content-type-example/sample-api</loc>');
59
+ expect(data.body).toContain(
60
+ '<loc>/docs/docs-content-type-example/widgets-api-reference</loc>',
61
+ );
60
62
  expect(data.body).toContain('<loc>/docs/docs-content-type-example/new-sample-api</loc>');
61
63
 
62
64
  // Each markdown URL appears exactly once.
@@ -0,0 +1,101 @@
1
+ class ContentActionToolbar {
2
+ HOST_SELECTOR = 'doc-content-action-toolbar';
3
+ INLINE_BUTTON_SELECTOR = 'dx-button.toolbar-button';
4
+ MENU_TRIGGER_SELECTOR = 'dx-button.menu-trigger';
5
+ MENU_SELECTOR = 'dx-dropdown.toolbar-menu';
6
+ MENU_GROUP_SELECTOR = 'div.menu_option';
7
+ MENU_HEADING_SELECTOR = 'span.menu_heading';
8
+ MENU_OPTION_SELECTOR = 'dx-dropdown-option';
9
+ OPTION_LABEL_SELECTOR = 'div.option_label';
10
+ COPIED_TOOLTIP_SELECTOR = '[role="tooltip"].is-visible';
11
+
12
+ async get(): Promise<WebdriverIO.Element> {
13
+ const toolbar = await $(`>>>${this.HOST_SELECTOR}`);
14
+ await toolbar.waitForDisplayed();
15
+ return toolbar;
16
+ }
17
+
18
+ async isPresent(): Promise<boolean> {
19
+ return (await $(`>>>${this.HOST_SELECTOR}`)).isExisting();
20
+ }
21
+
22
+ async getInlineButtonLabels(toolbar: WebdriverIO.Element): Promise<string[]> {
23
+ const inlineButtons = await toolbar.shadow$$(this.INLINE_BUTTON_SELECTOR);
24
+ return Promise.all(inlineButtons.map(async (button) => (await button.getText()).trim()));
25
+ }
26
+
27
+ async openMoreActionsMenu(toolbar: WebdriverIO.Element): Promise<void> {
28
+ const trigger = await toolbar.shadow$(this.MENU_TRIGGER_SELECTOR);
29
+ await trigger.waitForClickable();
30
+ await trigger.click();
31
+ }
32
+
33
+ async getMenuOptionLabelsByGroup(
34
+ toolbar: WebdriverIO.Element,
35
+ ): Promise<Record<string, string[]>> {
36
+ const dropdown = await toolbar.shadow$(this.MENU_SELECTOR);
37
+ const groupContainers = await dropdown.shadow$$(this.MENU_GROUP_SELECTOR);
38
+
39
+ const labelsByGroup: Record<string, string[]> = {};
40
+ for (const group of groupContainers) {
41
+ const heading = (await (await group.$(this.MENU_HEADING_SELECTOR)).getText()).trim();
42
+ const options = await group.$$(this.MENU_OPTION_SELECTOR);
43
+ labelsByGroup[heading] = await Promise.all(
44
+ options.map(async (option) =>
45
+ (await (await option.shadow$(this.OPTION_LABEL_SELECTOR)).getText()).trim(),
46
+ ),
47
+ );
48
+ }
49
+ return labelsByGroup;
50
+ }
51
+
52
+ async clickMenuOption(toolbar: WebdriverIO.Element, label: string): Promise<void> {
53
+ const dropdown = await toolbar.shadow$(this.MENU_SELECTOR);
54
+ const options = await dropdown.shadow$$(this.MENU_OPTION_SELECTOR);
55
+ for (const option of options) {
56
+ const optionLabel = (
57
+ await (await option.shadow$(this.OPTION_LABEL_SELECTOR)).getText()
58
+ ).trim();
59
+ if (optionLabel === label) {
60
+ await option.click();
61
+ return;
62
+ }
63
+ }
64
+ throw new Error(`Menu option "${label}" not found`);
65
+ }
66
+
67
+ async getUrlFromNewTab(action: () => Promise<void>): Promise<string> {
68
+ const originalHandle = await browser.getWindowHandle();
69
+ const handlesBefore = await browser.getWindowHandles();
70
+
71
+ await action();
72
+
73
+ await browser.waitUntil(
74
+ async (): Promise<boolean> =>
75
+ (await browser.getWindowHandles()).length > handlesBefore.length,
76
+ {
77
+ timeout: 10000,
78
+ timeoutMsg: 'Expected a new browser tab to open',
79
+ },
80
+ );
81
+
82
+ const handlesAfter = await browser.getWindowHandles();
83
+ const newHandle = handlesAfter.find((handle) => !handlesBefore.includes(handle));
84
+ if (!newHandle) {
85
+ throw new Error('New browser tab opened but its handle could not be resolved');
86
+ }
87
+ await browser.switchToWindow(newHandle);
88
+ const url = await browser.getUrl();
89
+
90
+ await browser.closeWindow();
91
+ await browser.switchToWindow(originalHandle);
92
+ return url;
93
+ }
94
+
95
+ async getVisibleCopiedTooltipLabel(): Promise<string> {
96
+ const tooltip = await $(`>>>${this.COPIED_TOOLTIP_SELECTOR}`);
97
+ return (await tooltip.getAttribute('aria-label'))?.trim() ?? '';
98
+ }
99
+ }
100
+
101
+ export default new ContentActionToolbar();
@@ -15,77 +15,23 @@ class DocsPage extends Page {
15
15
  });
16
16
  }
17
17
 
18
- async getContentHeading(): Promise<WebdriverIO.Element> {
19
- return (await $('doc-unified-content-layout')).$('div.markdown-content h1');
20
- }
21
-
22
- private async getSidebar(): Promise<WebdriverIO.Element> {
23
- return (await $('>>>doc-content-layout')).shadow$('div > dx-sidebar-old');
24
- }
25
-
26
- async waitForSidebar(): Promise<void> {
27
- await (await this.getSidebar()).waitForDisplayed();
28
- }
29
-
30
- async getSidebarTrees(): Promise<WebdriverIO.ElementArray> {
31
- const sidebar = await this.getSidebar();
32
- return sidebar.shadow$$('div.container div.sidebar-content dx-tree');
33
- }
34
-
35
- private async getTreeTile(treeItem: WebdriverIO.Element): Promise<WebdriverIO.Element> {
36
- await treeItem.waitForExist();
37
- return treeItem.shadow$('a > dx-tree-tile');
38
- }
39
-
40
- private async getTreeItemLabel(treeItem: WebdriverIO.Element): Promise<string> {
41
- const treeTile = await this.getTreeTile(treeItem);
42
- await treeTile.waitForExist();
43
- const label = await treeTile.shadow$('span.tile-label');
44
- return (await label.getText()).trim();
45
- }
46
-
47
- private async findTreeItemByLabel(
48
- treeItem: WebdriverIO.Element,
49
- label: string,
50
- ): Promise<WebdriverIO.Element | null> {
51
- if ((await this.getTreeItemLabel(treeItem)) === label) {
52
- return treeItem;
53
- }
54
-
55
- const children = await treeItem.shadow$$('dx-tree-item');
56
- for (const child of children) {
57
- const match = await this.findTreeItemByLabel(child, label);
58
- if (match) {
59
- return match;
60
- }
61
- }
62
- return null;
63
- }
64
-
65
18
  async getSidebarTile(label: string): Promise<WebdriverIO.Element> {
66
- const trees = await this.getSidebarTrees();
67
- for (const tree of trees) {
68
- const rootItem = await tree.shadow$('dx-tree-item');
69
- const match = await this.findTreeItemByLabel(rootItem, label);
70
- if (match) {
71
- return match;
72
- }
73
- }
74
- throw new Error(`No sidebar tile found with label "${label}"`);
19
+ return this.getSidebarTreeItemByLabel(label);
75
20
  }
76
21
 
77
22
  async getTileForwardArrow(label: string): Promise<WebdriverIO.Element> {
78
23
  const treeItem = await this.getSidebarTile(label);
79
- const treeTile = await this.getTreeTile(treeItem);
80
- await treeTile.waitForExist();
81
- return treeTile.shadow$('.tile-forward-arrow');
24
+ return (await this.getTreeItemTile(treeItem)).shadow$('.tile-forward-arrow');
82
25
  }
83
26
 
84
27
  async getTileExternalLinkIcon(label: string): Promise<WebdriverIO.Element> {
85
28
  const treeItem = await this.getSidebarTile(label);
86
- const treeTile = await this.getTreeTile(treeItem);
87
- await treeTile.waitForExist();
88
- return treeTile.shadow$('.tile-external-link-icon');
29
+ return (await this.getTreeItemTile(treeItem)).shadow$('.tile-external-link-icon');
30
+ }
31
+
32
+ async getTileAnchor(label: string): Promise<WebdriverIO.Element> {
33
+ const treeItem = await this.getSidebarTile(label);
34
+ return (await this.getTreeItemTile(treeItem)).shadow$('a');
89
35
  }
90
36
 
91
37
  async getRedocBackLink(): Promise<WebdriverIO.Element> {
@@ -119,8 +65,16 @@ class DocsPage extends Page {
119
65
  }
120
66
 
121
67
  async selectLanguage(displayText: string): Promise<void> {
68
+ const picker = await this.getLanguagePicker();
69
+ // A hidden mobile/desktop duplicate of the footer nav can overlap this button at the
70
+ // same screen coordinates, so a native pointer click risks getting intercepted by it.
71
+ await browser.execute((el: { click: () => void }) => el.click(), picker);
72
+
122
73
  const options = await $$('>>>dx-dropdown-option');
123
- for (const option of options) {
74
+ const isDisplayed = await Promise.all(options.map((option) => option.isDisplayed()));
75
+ const visibleOptions = options.filter((_, index) => isDisplayed[index]);
76
+
77
+ for (const option of visibleOptions) {
124
78
  const link = await option.shadow$('a.option');
125
79
  if ((await link.getText()).trim() === displayText) {
126
80
  await browser.execute((el: { click: () => void }) => el.click(), link);
@@ -58,6 +58,10 @@ export default class Page {
58
58
  ).waitForDisplayed({ timeout: this.REFERENCE_LOAD_WAIT_TIME });
59
59
  }
60
60
 
61
+ async getContentHeading(): Promise<WebdriverIO.Element> {
62
+ return (await $('doc-unified-content-layout')).$('div.markdown-content h1');
63
+ }
64
+
61
65
  async waitForGuidePageLoad(): Promise<void> {
62
66
  await $('doc-content-layout').waitForDisplayed({ timeout: 5000 });
63
67
  }
@@ -84,6 +88,80 @@ export default class Page {
84
88
  return await $('>>>doc-specification-content');
85
89
  }
86
90
 
91
+ async getTreeItemTile(treeItem: WebdriverIO.Element): Promise<WebdriverIO.Element> {
92
+ return treeItem.shadow$('dx-tree-tile');
93
+ }
94
+
95
+ async getSidebar(): Promise<WebdriverIO.Element> {
96
+ return (await $('>>>doc-content-layout')).shadow$('div > dx-sidebar-old');
97
+ }
98
+
99
+ async waitForSidebar(): Promise<void> {
100
+ await (await this.getSidebar()).waitForDisplayed();
101
+ }
102
+
103
+ async getSidebarTrees(): Promise<WebdriverIO.ElementArray> {
104
+ const sidebar = await this.getSidebar();
105
+ return sidebar.shadow$$('div.container div.sidebar-content dx-tree');
106
+ }
107
+
108
+ async getTreeItemLabel(treeItem: WebdriverIO.Element): Promise<string> {
109
+ const tile = await this.getTreeItemTile(treeItem);
110
+ const label = await tile.shadow$('span.tile-label');
111
+ return (await label.getText()).trim();
112
+ }
113
+
114
+ /**
115
+ * Toggles a parent tree item open without navigating — the expand icon's own click handler
116
+ * calls preventDefault/stopPropagation, unlike the tile's main link. Needed to reach a child
117
+ * row (e.g. a nested Redoc spec) that isn't rendered in the DOM until its parent is expanded.
118
+ */
119
+ async expandTreeItem(treeItem: WebdriverIO.Element): Promise<void> {
120
+ const tile = await this.getTreeItemTile(treeItem);
121
+ const icon = await tile.shadow$('.tile-expand-icon dx-icon');
122
+ await icon.click();
123
+ }
124
+
125
+ async findTreeItemByLabel(
126
+ treeItem: WebdriverIO.Element,
127
+ label: string,
128
+ ): Promise<WebdriverIO.Element | null> {
129
+ if ((await this.getTreeItemLabel(treeItem)) === label) {
130
+ return treeItem;
131
+ }
132
+
133
+ const children = await treeItem.shadow$$('dx-tree-item');
134
+ for (const child of children) {
135
+ const match = await this.findTreeItemByLabel(child, label);
136
+ if (match) {
137
+ return match;
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ async getSidebarTreeItemByLabel(label: string): Promise<WebdriverIO.Element> {
144
+ const trees = await this.getSidebarTrees();
145
+ for (const tree of trees) {
146
+ const rootItem = await tree.shadow$('dx-tree-item');
147
+ const match = await this.findTreeItemByLabel(rootItem, label);
148
+ if (match) {
149
+ return match;
150
+ }
151
+ }
152
+ throw new Error(`No sidebar tree item found with label "${label}"`);
153
+ }
154
+
155
+ /**
156
+ * Options render in a single shared overlay, one per picker on the page, hidden until their
157
+ * own dropdown opens — filtering to visible ones scopes this to whichever just opened.
158
+ */
159
+ async getVersionOptions(): Promise<WebdriverIO.Element[]> {
160
+ const allOptions = await $$('>>>doc-version-picker dx-dropdown-option');
161
+ const isDisplayed = await Promise.all(allOptions.map((option) => option.isDisplayed()));
162
+ return allOptions.filter((_, index) => isDisplayed[index]);
163
+ }
164
+
87
165
  async waitForSpinnerToDisappear(element: WebdriverIO.Element): Promise<void> {
88
166
  await browser.waitUntil(
89
167
  async function () {
@@ -28,7 +28,6 @@ export class ReferencePage extends Page {
28
28
  const versionPicker = await this.getVersionPickerBtn();
29
29
  await versionPicker.click();
30
30
  const versionOptions = await $$('>>>doc-version-picker dx-dropdown-option');
31
- await versionOptions[1].waitForClickable();
32
31
  await versionOptions[1].click();
33
32
  }
34
33
 
@@ -0,0 +1,196 @@
1
+ import Page from './page';
2
+
3
+ /** Must match the sessionStorage key the doc-version-picker component writes to. */
4
+ const TOPIC_VERSION_SELECTIONS_SESSION_KEY = 'sfdocs-topic-version-selections';
5
+
6
+ /**
7
+ * Page object for topic-level (per-topic) version picker interactions.
8
+ *
9
+ * Two `doc-version-picker` instances exist for a versioned topic:
10
+ * - the SIDEBAR picker (small variant), rendered as a sibling of `dx-tree-tile` inside the
11
+ * versioned tree row's own shadow root (`class="tree-item-version-picker"`).
12
+ * - the REDOC SPEC-PAGE picker (default variant), rendered inside `div.redoc-project-header`
13
+ * alongside the back-link/spec title. Redoc mounts this header into BOTH `.menu-content` and
14
+ * `.api-content`, so we always scope through the sidebar's copy (`getRedocProjectHeader`) to
15
+ * avoid matching the duplicate.
16
+ */
17
+ export default class TopicVersionPage extends Page {
18
+ async waitForMarkdownTopicLoad(): Promise<void> {
19
+ await (
20
+ await $('>>>doc-content-layout')
21
+ ).waitForDisplayed({ timeout: this.REFERENCE_LOAD_WAIT_TIME });
22
+ }
23
+
24
+ async waitForRedocTopicLoad(): Promise<void> {
25
+ await $('doc-redoc-reference > .redoc-container').waitForDisplayed({
26
+ timeout: this.REFERENCE_LOAD_WAIT_TIME,
27
+ });
28
+ }
29
+
30
+ async getSidebarTreeItem(label: string): Promise<WebdriverIO.Element> {
31
+ return this.getSidebarTreeItemByLabel(label);
32
+ }
33
+
34
+ /** The sidebar row's own doc-version-picker (small variant), or null if it has none. */
35
+ async getSidebarPicker(rowLabel: string): Promise<WebdriverIO.Element | null> {
36
+ const treeItem = await this.getSidebarTreeItem(rowLabel);
37
+ const picker = await treeItem.shadow$('.tree-item-version-picker');
38
+ return (await picker.isExisting()) ? picker : null;
39
+ }
40
+
41
+ // --- Redoc spec-page project header (default variant) ---
42
+
43
+ private async getRedocSidebar(): Promise<WebdriverIO.Element> {
44
+ return $('.redoc-wrap > .menu-content');
45
+ }
46
+
47
+ private async getRedocProjectHeader(): Promise<WebdriverIO.Element> {
48
+ return (await this.getRedocSidebar()).$('div.redoc-project-header');
49
+ }
50
+
51
+ /** Back link in the menu header, not the duplicate inside api-content. */
52
+ async getRedocBackLink(): Promise<WebdriverIO.Element> {
53
+ return (await this.getRedocProjectHeader()).$('a.redoc-project-back');
54
+ }
55
+
56
+ /** The Redoc spec-page's own doc-version-picker, or null if this spec has no versions. */
57
+ async getRedocPicker(): Promise<WebdriverIO.Element | null> {
58
+ const header = await this.getRedocProjectHeader();
59
+ const wrapper = await header.$('.redoc-version-picker');
60
+ if (!(await wrapper.isExisting())) {
61
+ return null;
62
+ }
63
+ return wrapper.$('doc-version-picker');
64
+ }
65
+
66
+ /** Clicks a sidebar tree item and waits for the Redoc page it opens to load. */
67
+ async openRedocFromSidebar(label: string): Promise<void> {
68
+ const item = await this.getSidebarTreeItem(label);
69
+ await item.click();
70
+ await this.waitForRedocTopicLoad();
71
+ }
72
+
73
+ /** Selects a version on the Redoc page's own picker and waits for the new spec to load. */
74
+ async selectRedocVersionByLabel(label: string): Promise<void> {
75
+ const picker = await this.getRedocPicker();
76
+ await this.selectVersionByLabel(picker!, label);
77
+ await this.waitForRedocTopicLoad();
78
+ }
79
+
80
+ /** Clicks the Redoc back link and waits for the markdown page it returns to load. */
81
+ async goBackFromRedoc(): Promise<void> {
82
+ await (await this.getRedocBackLink()).click();
83
+ await this.waitForMarkdownTopicLoad();
84
+ }
85
+
86
+ /**
87
+ * The dismissible "Newer Version Available" banner shown on a non-latest Redoc spec version.
88
+ * Reuses the `doc-phase` component, inserted at the top of `.api-content`. Returns null when
89
+ * absent (latest version, single version, or no versions).
90
+ */
91
+ async getRedocVersionBanner(): Promise<WebdriverIO.Element | null> {
92
+ const banner = await $(
93
+ 'doc-redoc-reference > .redoc-container .api-content .doc-phase-wrapper doc-phase',
94
+ );
95
+ return (await banner.isExisting()) ? banner : null;
96
+ }
97
+
98
+ /**
99
+ * The markdown-topic analog of `getRedocVersionBanner`.
100
+ */
101
+ async getMarkdownVersionBanner(): Promise<WebdriverIO.Element | null> {
102
+ const banner = await $('>>>doc-content-layout doc-phase[slot="version-banner"]');
103
+ return (await banner.isExisting()) ? banner : null;
104
+ }
105
+
106
+ async getBannerTitle(banner: WebdriverIO.Element): Promise<string> {
107
+ return (await banner.shadow$('.doc-phase-title')).getText();
108
+ }
109
+
110
+ async getBannerLinkHref(banner: WebdriverIO.Element): Promise<string> {
111
+ const link = await (await banner.shadow$('.doc-phase-body')).$('a');
112
+ return link.getAttribute('href');
113
+ }
114
+
115
+ // --- Generic picker interactions (work for either variant) ---
116
+
117
+ async getSelectedVersionLabel(picker: WebdriverIO.Element): Promise<string> {
118
+ const label = await picker.shadow$('p.selected-version-label');
119
+ return (await label.getText()).trim();
120
+ }
121
+
122
+ async isReadOnly(picker: WebdriverIO.Element): Promise<boolean> {
123
+ return (await picker.shadow$('p.version-picker-readonly')).isExisting();
124
+ }
125
+
126
+ /** Default (non-small) variant only — the redoc spec-page picker. */
127
+ async isLatestBadgeShown(picker: WebdriverIO.Element): Promise<boolean> {
128
+ return (await picker.shadow$('dx-type-badge.latest-badge')).isExisting();
129
+ }
130
+
131
+ async isNotLatestBadgeShown(picker: WebdriverIO.Element): Promise<boolean> {
132
+ return (await picker.shadow$('dx-type-badge.not-latest-badge')).isExisting();
133
+ }
134
+
135
+ /** Small variant only — the sidebar picker shows a colored dot instead of a badge. */
136
+ async isLatestDotShown(picker: WebdriverIO.Element): Promise<boolean> {
137
+ return (await picker.shadow$('span.version-picker-dot-latest')).isExisting();
138
+ }
139
+
140
+ async isNotLatestDotShown(picker: WebdriverIO.Element): Promise<boolean> {
141
+ return (await picker.shadow$('span.version-picker-dot-not-latest')).isExisting();
142
+ }
143
+
144
+ async openPicker(picker: WebdriverIO.Element): Promise<void> {
145
+ const trigger = await picker.shadow$('.version-picker-button');
146
+ await trigger.waitForDisplayed();
147
+ await trigger.waitForClickable();
148
+ await trigger.click();
149
+ }
150
+
151
+ async getOptionLabel(option: WebdriverIO.Element): Promise<string> {
152
+ const label = await option.shadow$('div>div');
153
+ return (await label.getText()).trim();
154
+ }
155
+
156
+ async selectVersionByLabel(picker: WebdriverIO.Element, label: string): Promise<void> {
157
+ await this.openPicker(picker);
158
+ const options = await this.getVersionOptions();
159
+ for (const option of options) {
160
+ if ((await this.getOptionLabel(option)) === label) {
161
+ await option.waitForClickable();
162
+ await option.click();
163
+ return;
164
+ }
165
+ }
166
+ throw new Error(`No version option found with label "${label}"`);
167
+ }
168
+
169
+ private async waitForUrlCondition(
170
+ description: string,
171
+ predicate: (url: string) => boolean,
172
+ ): Promise<void> {
173
+ await browser.waitUntil(async () => predicate(await browser.getUrl()), {
174
+ timeout: 15000,
175
+ timeoutMsg: `expected URL to ${description}`,
176
+ });
177
+ }
178
+
179
+ async waitForUrlToContain(path: string): Promise<void> {
180
+ await this.waitForUrlCondition(`contain ${path}`, (url) => url.includes(path));
181
+ }
182
+
183
+ async waitForUrlToEndWith(path: string): Promise<void> {
184
+ await this.waitForUrlCondition(`end with ${path}`, (url) => url.endsWith(path));
185
+ }
186
+
187
+ async readSessionVersionSelections(): Promise<Record<string, string> | null> {
188
+ // The tsconfig's lib list omits "DOM", so sessionStorage isn't a known global here even
189
+ // though this callback runs in the browser — route through globalThis to sidestep that.
190
+ const raw = await browser.execute(
191
+ (key) => (globalThis as any).sessionStorage.getItem(key),
192
+ TOPIC_VERSION_SELECTIONS_SESSION_KEY,
193
+ );
194
+ return raw ? JSON.parse(raw) : null;
195
+ }
196
+ }