@salesforcedevs/sfdocs-e2e 1.0.11-toolbar.2 → 1.0.12-e2e-topic-version-1

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.11-toolbar.2",
3
+ "version": "1.0.12-e2e-topic-version-1",
4
4
  "description": "E2E test suite for SFDocs static portal pages (SSG)",
5
5
  "license": "ISC",
6
6
  "bin": {
@@ -33,7 +33,7 @@ class DocsPage extends Page {
33
33
  }
34
34
 
35
35
  private async getTreeTile(treeItem: WebdriverIO.Element): Promise<WebdriverIO.Element> {
36
- return treeItem.shadow$('a > dx-tree-tile');
36
+ return this.getTreeItemTile(treeItem);
37
37
  }
38
38
 
39
39
  private async getTreeItemLabel(treeItem: WebdriverIO.Element): Promise<string> {
@@ -82,6 +82,11 @@ class DocsPage extends Page {
82
82
  return (await this.getTreeTile(treeItem)).shadow$('.tile-external-link-icon');
83
83
  }
84
84
 
85
+ async getTileAnchor(label: string): Promise<WebdriverIO.Element> {
86
+ const treeItem = await this.getSidebarTile(label);
87
+ return (await this.getTreeTile(treeItem)).shadow$('a');
88
+ }
89
+
85
90
  async getRedocBackLink(): Promise<WebdriverIO.Element> {
86
91
  return $('>>>a.redoc-project-back');
87
92
  }
@@ -84,6 +84,10 @@ export default class Page {
84
84
  return await $('>>>doc-specification-content');
85
85
  }
86
86
 
87
+ async getTreeItemTile(treeItem: WebdriverIO.Element): Promise<WebdriverIO.Element> {
88
+ return treeItem.shadow$('dx-tree-tile');
89
+ }
90
+
87
91
  async waitForSpinnerToDisappear(element: WebdriverIO.Element): Promise<void> {
88
92
  await browser.waitUntil(
89
93
  async function () {
@@ -0,0 +1,201 @@
1
+ import Page from './page';
2
+
3
+ /**
4
+ * Page object for topic-level (per-topic) version picker interactions.
5
+ *
6
+ * Two `doc-version-picker` instances exist for a versioned topic:
7
+ * - the SIDEBAR picker (small variant), rendered as a sibling of `dx-tree-tile` inside the
8
+ * versioned tree row's own shadow root (`class="tree-item-version-picker"`).
9
+ * - the REDOC SPEC-PAGE picker (default variant), rendered inside `div.redoc-project-header`
10
+ * alongside the back-link/spec title. Redoc mounts this header into BOTH `.menu-content` and
11
+ * `.api-content`, so we always scope through the sidebar's copy (`getRedocProjectHeader`) to
12
+ * avoid matching the duplicate.
13
+ */
14
+ export default class TopicVersionPage extends Page {
15
+ async waitForMarkdownTopicLoad(): Promise<void> {
16
+ await (
17
+ await $('>>>doc-content-layout')
18
+ ).waitForDisplayed({ timeout: this.REFERENCE_LOAD_WAIT_TIME });
19
+ }
20
+
21
+ async waitForRedocTopicLoad(): Promise<void> {
22
+ await $('doc-redoc-reference > .redoc-container').waitForDisplayed({
23
+ timeout: this.REFERENCE_LOAD_WAIT_TIME,
24
+ });
25
+ }
26
+
27
+ // --- Sidebar tree traversal (mirrors docs.page.ts's private helpers, exposed here so
28
+ // individual rows and their pickers can be located directly) ---
29
+
30
+ async getSidebar(): Promise<WebdriverIO.Element> {
31
+ return (await $('>>>doc-content-layout')).shadow$('div > dx-sidebar-old');
32
+ }
33
+
34
+ async waitForSidebar(): Promise<void> {
35
+ await (await this.getSidebar()).waitForDisplayed();
36
+ }
37
+
38
+ private async getTreeItemLabel(treeItem: WebdriverIO.Element): Promise<string> {
39
+ const tile = await this.getTreeItemTile(treeItem);
40
+ const label = await tile.shadow$('span.tile-label');
41
+ return (await label.getText()).trim();
42
+ }
43
+
44
+ private async findTreeItemByLabel(
45
+ treeItem: WebdriverIO.Element,
46
+ label: string,
47
+ ): Promise<WebdriverIO.Element | null> {
48
+ if ((await this.getTreeItemLabel(treeItem)) === label) {
49
+ return treeItem;
50
+ }
51
+
52
+ const children = await treeItem.shadow$$('dx-tree-item');
53
+ for (const child of children) {
54
+ const match = await this.findTreeItemByLabel(child, label);
55
+ if (match) {
56
+ return match;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /** Locates a sidebar tree row (dx-tree-item) by its visible label. */
63
+ async getSidebarTreeItem(label: string): Promise<WebdriverIO.Element> {
64
+ const sidebar = await this.getSidebar();
65
+ const trees = await sidebar.shadow$$('div.container div.sidebar-content dx-tree');
66
+ for (const tree of trees) {
67
+ const rootItem = await tree.shadow$('dx-tree-item');
68
+ const match = await this.findTreeItemByLabel(rootItem, label);
69
+ if (match) {
70
+ return match;
71
+ }
72
+ }
73
+ throw new Error(`No sidebar tree item found with label "${label}"`);
74
+ }
75
+
76
+ /** The sidebar row's own doc-version-picker (small variant), or null if it has none. */
77
+ async getSidebarPicker(rowLabel: string): Promise<WebdriverIO.Element | null> {
78
+ const treeItem = await this.getSidebarTreeItem(rowLabel);
79
+ const picker = await treeItem.shadow$('.tree-item-version-picker');
80
+ return (await picker.isExisting()) ? picker : null;
81
+ }
82
+
83
+ // --- Redoc spec-page project header (default variant) ---
84
+
85
+ private async getRedocSidebar(): Promise<WebdriverIO.Element> {
86
+ return $('.redoc-wrap > .menu-content');
87
+ }
88
+
89
+ private async getRedocProjectHeader(): Promise<WebdriverIO.Element> {
90
+ return (await this.getRedocSidebar()).$('div.redoc-project-header');
91
+ }
92
+
93
+ /** The Redoc spec-page's own doc-version-picker, or null if this spec has no versions. */
94
+ async getRedocPicker(): Promise<WebdriverIO.Element | null> {
95
+ const header = await this.getRedocProjectHeader();
96
+ const wrapper = await header.$('.redoc-version-picker');
97
+ if (!(await wrapper.isExisting())) {
98
+ return null;
99
+ }
100
+ return wrapper.$('doc-version-picker');
101
+ }
102
+
103
+ /**
104
+ * The dismissible "Newer Version Available" banner shown on a non-latest Redoc spec version.
105
+ * Reuses the `doc-phase` component, inserted at the top of `.api-content`. Returns null when
106
+ * absent (latest version, single version, or no versions).
107
+ */
108
+ async getRedocVersionBanner(): Promise<WebdriverIO.Element | null> {
109
+ const banner = await $(
110
+ 'doc-redoc-reference > .redoc-container .api-content .doc-phase-wrapper doc-phase',
111
+ );
112
+ return (await banner.isExisting()) ? banner : null;
113
+ }
114
+
115
+ /**
116
+ * The markdown-topic analog of `getRedocVersionBanner`. TODO: unconfirmed whether markdown
117
+ * topics render this banner at all yet — only the Redoc spec-page banner is confirmed shipped.
118
+ */
119
+ async getMarkdownVersionBanner(): Promise<WebdriverIO.Element | null> {
120
+ const banner = await $('>>>doc-content-layout .doc-phase-wrapper doc-phase');
121
+ return (await banner.isExisting()) ? banner : null;
122
+ }
123
+
124
+ async getBannerTitle(banner: WebdriverIO.Element): Promise<string> {
125
+ return (await banner.shadow$('.doc-phase-title')).getText();
126
+ }
127
+
128
+ async getBannerLinkHref(banner: WebdriverIO.Element): Promise<string> {
129
+ const link = await (await banner.shadow$('.doc-phase-body')).$('a');
130
+ return link.getAttribute('href');
131
+ }
132
+
133
+ // --- Generic picker interactions (work for either variant) ---
134
+
135
+ async getSelectedVersionLabel(picker: WebdriverIO.Element): Promise<string> {
136
+ const label = await picker.shadow$('p.selected-version-label');
137
+ return (await label.getText()).trim();
138
+ }
139
+
140
+ async isReadOnly(picker: WebdriverIO.Element): Promise<boolean> {
141
+ return (await picker.shadow$('p.version-picker-readonly')).isExisting();
142
+ }
143
+
144
+ /** Default (non-small) variant only — the redoc spec-page picker. */
145
+ async isLatestBadgeShown(picker: WebdriverIO.Element): Promise<boolean> {
146
+ return (await picker.shadow$('dx-type-badge.latest-badge')).isExisting();
147
+ }
148
+
149
+ async isNotLatestBadgeShown(picker: WebdriverIO.Element): Promise<boolean> {
150
+ return (await picker.shadow$('dx-type-badge.not-latest-badge')).isExisting();
151
+ }
152
+
153
+ /** Small variant only — the sidebar picker shows a colored dot instead of a badge. */
154
+ async isLatestDotShown(picker: WebdriverIO.Element): Promise<boolean> {
155
+ return (await picker.shadow$('span.version-picker-dot-latest')).isExisting();
156
+ }
157
+
158
+ async isNotLatestDotShown(picker: WebdriverIO.Element): Promise<boolean> {
159
+ return (await picker.shadow$('span.version-picker-dot-not-latest')).isExisting();
160
+ }
161
+
162
+ async openPicker(picker: WebdriverIO.Element): Promise<void> {
163
+ const trigger = await picker.shadow$('.version-picker-button');
164
+ await trigger.waitForDisplayed();
165
+ await trigger.waitForClickable();
166
+ await trigger.click();
167
+ }
168
+
169
+ /** Dropdown options render in a single shared overlay, so this is a global (unscoped) query. */
170
+ async getVersionOptions(): Promise<WebdriverIO.ElementArray> {
171
+ return $$('>>>doc-version-picker dx-dropdown-option');
172
+ }
173
+
174
+ async getOptionLabel(option: WebdriverIO.Element): Promise<string> {
175
+ const label = await option.shadow$('div>div');
176
+ return (await label.getText()).trim();
177
+ }
178
+
179
+ /** Opens the picker and clicks the option whose label matches exactly. */
180
+ async selectVersionByLabel(picker: WebdriverIO.Element, label: string): Promise<void> {
181
+ await this.openPicker(picker);
182
+ const options = await this.getVersionOptions();
183
+ for (const option of options) {
184
+ if ((await this.getOptionLabel(option)) === label) {
185
+ await option.waitForClickable();
186
+ await option.click();
187
+ return;
188
+ }
189
+ }
190
+ throw new Error(`No version option found with label "${label}"`);
191
+ }
192
+
193
+ async readSessionVersionSelections(): Promise<Record<string, string> | null> {
194
+ // The tsconfig's lib list omits "DOM", so sessionStorage isn't a known global here even
195
+ // though this callback runs in the browser — route through globalThis to sidestep that.
196
+ const raw = await browser.execute(() =>
197
+ (globalThis as any).sessionStorage.getItem('sfdocs-topic-version-selections'),
198
+ );
199
+ return raw ? JSON.parse(raw) : null;
200
+ }
201
+ }
@@ -1,7 +1,6 @@
1
1
  import DocsPage from '../pageobjects/docs.page';
2
2
  import GuidePage from '../pageobjects/guide.page';
3
3
  import { ReferencePage } from '../pageobjects/reference.page';
4
- import contentActionToolbar from '../pageobjects/content-action-toolbar';
5
4
 
6
5
  const DOCS_MARKDOWN_PATH = '/docs/docs-content-type-example/get-started.html';
7
6
 
@@ -11,154 +10,63 @@ const EN_US_MARKDOWN_REFERENCE_PATH =
11
10
  const NON_MARKDOWN_REFERENCE_PATH = '/docs/test-metamind-oas-api/references/1mkfx9?meta=Summary';
12
11
  const NON_EN_US_MARKDOWN_REFERENCE_PATH =
13
12
  '/docs/commerce/ja-jp/flex-references-loc/references/manual-ref/commerce-cloud-api-overview.html';
14
- const INLINE_MARKDOWN_LABELS = ['Copy as Markdown', 'View as Markdown', 'View llms.txt'];
15
- const MENU_GROUPS = {
16
- 'This Page': [
17
- 'Copy as Markdown',
18
- 'View as Markdown',
19
- 'Copy URL to Markdown',
20
- 'Open in ChatGPT',
21
- 'Open in Claude',
22
- 'Open in Perplexity',
23
- ],
24
- 'All Documentation': ['View llms.txt', 'Copy URL to llms.txt'],
25
- };
26
- const AI_ASSISTANTS = [
27
- { label: 'Open in ChatGPT', host: 'chatgpt.com' },
28
- { label: 'Open in Claude', host: 'claude.ai' },
29
- { label: 'Open in Perplexity', host: 'www.perplexity.ai' },
30
- ];
31
- const LLMS_TXT_PATH = '/docs/llms.txt';
32
- const COPIED_TOOLTIP_LABEL = 'Copied!';
13
+
14
+ async function expectToolbarButtons(toolbar: WebdriverIO.Element): Promise<void> {
15
+ const toolbarButtons = await toolbar.shadow$$('dx-button.toolbar-button');
16
+ expect(toolbarButtons.length).toBe(3);
17
+
18
+ const labels = await Promise.all(
19
+ toolbarButtons.map(async (button) => (await button.getText()).trim()),
20
+ );
21
+ expect(labels).toEqual(
22
+ expect.arrayContaining(['Copy as Markdown', 'View as Markdown', 'Copy URL to Markdown']),
23
+ );
24
+ }
33
25
 
34
26
  describe('Content action toolbar scenarios', () => {
35
- it('should show the inline markdown actions when opening a markdown guide', async () => {
27
+ it('Markdown guide should have the toolbar', async () => {
36
28
  const page = new GuidePage(EN_US_GUIDE_PATH);
37
29
  page.open();
38
30
  await page.waitPageLoad();
39
31
 
40
- const toolbar = await contentActionToolbar.get();
41
- expect(await contentActionToolbar.getInlineButtonLabels(toolbar)).toEqual(
42
- expect.arrayContaining(INLINE_MARKDOWN_LABELS),
43
- );
32
+ const toolbar = await $('>>>doc-content-action-toolbar');
33
+ await toolbar.waitForDisplayed();
34
+ await expectToolbarButtons(toolbar);
44
35
  });
45
36
 
46
- it('should show the inline markdown actions when opening a markdown reference', async () => {
37
+ it('Markdown reference should have the toolbar', async () => {
47
38
  const referencePage = new ReferencePage(EN_US_MARKDOWN_REFERENCE_PATH);
48
39
  referencePage.open();
49
40
  await referencePage.waitForMarkdownBasedReferencePageLoad();
50
41
 
51
- const toolbar = await contentActionToolbar.get();
52
- expect(await contentActionToolbar.getInlineButtonLabels(toolbar)).toEqual(
53
- expect.arrayContaining(INLINE_MARKDOWN_LABELS),
54
- );
42
+ const toolbar = await $('>>>doc-content-action-toolbar');
43
+ await toolbar.waitForDisplayed();
44
+ await expectToolbarButtons(toolbar);
55
45
  });
56
46
 
57
- it('should show the inline markdown actions when opening a markdown docs page', async () => {
47
+ it('Markdown docs page should have the toolbar', async () => {
58
48
  const page = new DocsPage(DOCS_MARKDOWN_PATH);
59
49
  page.open();
60
50
  await page.waitPageLoad();
61
51
 
62
- const toolbar = await contentActionToolbar.get();
63
- expect(await contentActionToolbar.getInlineButtonLabels(toolbar)).toEqual(
64
- expect.arrayContaining(INLINE_MARKDOWN_LABELS),
65
- );
66
- });
67
-
68
- it('should expose the full grouped action set through the More actions menu on a markdown page', async () => {
69
- const page = new GuidePage(EN_US_GUIDE_PATH);
70
- page.open();
71
- await page.waitPageLoad();
72
-
73
- const toolbar = await contentActionToolbar.get();
74
- await contentActionToolbar.openMoreActionsMenu(toolbar);
75
-
76
- const labelsByGroup = await contentActionToolbar.getMenuOptionLabelsByGroup(toolbar);
77
- expect(Object.keys(labelsByGroup)).toEqual(
78
- expect.arrayContaining(Object.keys(MENU_GROUPS)),
79
- );
80
- for (const [group, expectedLabels] of Object.entries(MENU_GROUPS)) {
81
- expect(labelsByGroup[group]).toEqual(expect.arrayContaining(expectedLabels));
82
- }
52
+ const toolbar = await $('>>>doc-content-action-toolbar');
53
+ await toolbar.waitForDisplayed();
54
+ await expectToolbarButtons(toolbar);
83
55
  });
84
56
 
85
- AI_ASSISTANTS.forEach(({ label, host }) => {
86
- it(`should open ${host} with the page context when selecting ${label}`, async () => {
87
- const page = new GuidePage(EN_US_GUIDE_PATH);
88
- page.open();
89
- await page.waitPageLoad();
90
-
91
- const toolbar = await contentActionToolbar.get();
92
- await contentActionToolbar.openMoreActionsMenu(toolbar);
93
-
94
- const url = await contentActionToolbar.getUrlFromNewTab(() =>
95
- contentActionToolbar.clickMenuOption(toolbar, label),
96
- );
97
- expect(url).toContain(host);
98
- });
99
- });
100
-
101
- it('should open the markdown version of the page in a new tab when selecting View as Markdown', async () => {
102
- const page = new GuidePage(EN_US_GUIDE_PATH);
103
- page.open();
104
- await page.waitPageLoad();
105
-
106
- const toolbar = await contentActionToolbar.get();
107
- await contentActionToolbar.openMoreActionsMenu(toolbar);
108
-
109
- const url = await contentActionToolbar.getUrlFromNewTab(() =>
110
- contentActionToolbar.clickMenuOption(toolbar, 'View as Markdown'),
111
- );
112
- expect(url.endsWith('.md')).toBe(true);
113
- });
114
-
115
- it('should open the site llms.txt in a new tab when selecting View llms.txt', async () => {
116
- const page = new GuidePage(EN_US_GUIDE_PATH);
117
- page.open();
118
- await page.waitPageLoad();
119
-
120
- const toolbar = await contentActionToolbar.get();
121
- await contentActionToolbar.openMoreActionsMenu(toolbar);
122
-
123
- const url = await contentActionToolbar.getUrlFromNewTab(() =>
124
- contentActionToolbar.clickMenuOption(toolbar, 'View llms.txt'),
125
- );
126
- expect(url.endsWith(LLMS_TXT_PATH)).toBe(true);
127
- });
128
-
129
- it('should flash the Copied! tooltip when selecting Copy URL to Markdown', async () => {
130
- const page = new GuidePage(EN_US_GUIDE_PATH);
131
- page.open();
132
- await page.waitPageLoad();
133
-
134
- const toolbar = await contentActionToolbar.get();
135
- await contentActionToolbar.openMoreActionsMenu(toolbar);
136
- await contentActionToolbar.clickMenuOption(toolbar, 'Copy URL to Markdown');
137
-
138
- await browser.waitUntil(
139
- async () =>
140
- (await contentActionToolbar.getVisibleCopiedTooltipLabel()) ===
141
- COPIED_TOOLTIP_LABEL,
142
- {
143
- timeout: 5000,
144
- timeoutMsg: 'Expected the menu tooltip to flash "Copied!"',
145
- },
146
- );
147
- });
148
-
149
- it('should not show the toolbar on a non-markdown reference', async () => {
57
+ it("Non-markdown reference shouldn't have toolbar", async () => {
150
58
  const referencePage = new ReferencePage(NON_MARKDOWN_REFERENCE_PATH);
151
59
  referencePage.open();
152
60
  await referencePage.waitForSpecBasedReferencePageLoad();
153
61
 
154
- expect(await contentActionToolbar.isPresent()).toBeFalsy();
62
+ const toolbar = await $('>>>doc-content-action-toolbar');
63
+ expect(await toolbar.isExisting()).toBeFalsy();
155
64
  });
156
65
 
157
- it('should not show the toolbar on a non en-us page', async () => {
66
+ it("Non en-us locale page shouldn't have toolbar", async () => {
158
67
  const nonEnUsReferencePage = new ReferencePage(NON_EN_US_MARKDOWN_REFERENCE_PATH);
159
68
  nonEnUsReferencePage.open();
160
69
  await nonEnUsReferencePage.waitForMarkdownBasedReferencePageLoad();
161
-
162
- expect(await contentActionToolbar.isPresent()).toBeFalsy();
70
+ expect(await (await $('>>>doc-content-action-toolbar')).isExisting()).toBeFalsy();
163
71
  });
164
72
  });
@@ -355,8 +355,7 @@ describe('Docs content type > external link', () => {
355
355
  await page.waitPageLoad();
356
356
  await page.waitForSidebar();
357
357
 
358
- const tile = await page.getSidebarTile('Salesforce Developers');
359
- const anchor = await tile.shadow$('a');
358
+ const anchor = await page.getTileAnchor('Salesforce Developers');
360
359
  expect(await anchor.getAttribute('href')).toContain('https://developer.salesforce.com');
361
360
  expect(await anchor.getAttribute('target')).toBe('_blank');
362
361
  });
@@ -187,11 +187,9 @@ describe('Redoc API Reference page', () => {
187
187
 
188
188
  it('should show arrow button after OAS reference titles', async () => {
189
189
  const sideBarContent = await refPage.getSidebarItems();
190
-
191
- const secondItem = sideBarContent[1];
192
- const treeItem = await secondItem.shadow$('dx-tree-item');
193
- const treeTile = await treeItem.shadow$('a > dx-tree-tile');
194
- const arrowIcon = await treeTile.shadow$(
190
+ const treeItem = await sideBarContent[1].shadow$('dx-tree-item');
191
+ const tile = await refPage.getTreeItemTile(treeItem);
192
+ const arrowIcon = await tile.shadow$(
195
193
  'a > div[role="heading"] > div.tile-forward-arrow',
196
194
  );
197
195
 
@@ -0,0 +1,432 @@
1
+ import { it } from 'mocha';
2
+ import TopicVersionPage from '../pageobjects/topic-version.page';
3
+
4
+ // Fixtures live in the `docs-content-type-example` project, kept in sync between this monorepo's
5
+ // local `packages/example-repository` and the remote `salesforcedocs/example-repository`'s
6
+ // `mh/topic-level-version` branch.
7
+ const BASE = '/docs/docs-content-type-example';
8
+
9
+ let page: TopicVersionPage;
10
+
11
+ describe('Topic-level versioning > versioned markdown topic', () => {
12
+ // fixture: OAuth — latest v2 'WI'26' / older v1 'SU'26'.
13
+ beforeEach(async () => {
14
+ page = new TopicVersionPage(`${BASE}/oauth.html`);
15
+ page.open();
16
+ await page.waitForMarkdownTopicLoad();
17
+ });
18
+
19
+ it('should render the picker and default to the latest version', async () => {
20
+ const picker = await page.getSidebarPicker('OAuth');
21
+ expect(picker).not.toBeNull();
22
+ expect(await page.getSelectedVersionLabel(picker!)).toEqual("WI'26");
23
+ expect(await page.isLatestDotShown(picker!)).toBeTruthy();
24
+ });
25
+
26
+ it('should list all versions in declared order when the picker is opened', async () => {
27
+ const picker = await page.getSidebarPicker('OAuth');
28
+ await page.openPicker(picker!);
29
+
30
+ const options = await page.getVersionOptions();
31
+ expect(options.length).toEqual(2);
32
+ expect(await page.getOptionLabel(options[0])).toEqual("WI'26");
33
+ expect(await page.getOptionLabel(options[1])).toEqual("SU'26");
34
+ });
35
+
36
+ it('should switch the page content and URL when a non-latest version is selected', async () => {
37
+ const picker = await page.getSidebarPicker('OAuth');
38
+ await page.selectVersionByLabel(picker!, "SU'26");
39
+ await page.waitForMarkdownTopicLoad();
40
+
41
+ expect(await (await $('>>>h1')).getText()).toEqual('OAuth 1.0');
42
+ await browser.waitUntil(
43
+ async () => (await browser.getUrl()).includes(`${BASE}/v1/oauth.html`),
44
+ { timeoutMsg: 'expected URL to contain /v1/oauth.html' },
45
+ );
46
+ });
47
+
48
+ it('should return to the canonical URL when the latest version is reselected', async () => {
49
+ page = new TopicVersionPage(`${BASE}/v1/oauth.html`);
50
+ page.open();
51
+ await page.waitForMarkdownTopicLoad();
52
+
53
+ const picker = await page.getSidebarPicker('OAuth');
54
+ await page.selectVersionByLabel(picker!, "WI'26");
55
+ await page.waitForMarkdownTopicLoad();
56
+
57
+ const url = await browser.getUrl();
58
+ expect(url.endsWith(`${BASE}/oauth.html`)).toBeTruthy();
59
+ expect(url).not.toContain('/v2/');
60
+ expect(await (await $('>>>h1')).getText()).toEqual('OAuth 2.0');
61
+ });
62
+
63
+ // TODO: unconfirmed whether markdown topics render this banner at all yet (only Redoc spec
64
+ // pages are confirmed to). Enable once verified.
65
+ it.skip('should show the "Newer Version Available" banner when the version is not latest', async () => {
66
+ page = new TopicVersionPage(`${BASE}/v1/oauth.html`);
67
+ page.open();
68
+ await page.waitForMarkdownTopicLoad();
69
+
70
+ const banner = await page.getMarkdownVersionBanner();
71
+ expect(banner).not.toBeNull();
72
+ });
73
+
74
+ it('should not show the "Newer Version Available" banner when the version is latest', async () => {
75
+ const banner = await page.getMarkdownVersionBanner();
76
+ expect(banner).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe('Topic-level versioning > versioned parent with versioned child subtree', () => {
81
+ // fixture: Client SDKs — latest v2 'WI'26' / older v1 'SU'26'.
82
+ beforeEach(async () => {
83
+ page = new TopicVersionPage(`${BASE}/android.html`);
84
+ page.open();
85
+ await page.waitForMarkdownTopicLoad();
86
+ });
87
+
88
+ it('should render the picker on the parent and default to the latest version', async () => {
89
+ const picker = await page.getSidebarPicker('Client SDKs');
90
+ expect(picker).not.toBeNull();
91
+ expect(await page.getSelectedVersionLabel(picker!)).toEqual("WI'26");
92
+ });
93
+
94
+ it('should switch the whole child subtree when the parent version changes', async () => {
95
+ const picker = await page.getSidebarPicker('Client SDKs');
96
+ await page.selectVersionByLabel(picker!, "SU'26");
97
+ await page.waitForMarkdownTopicLoad();
98
+
99
+ await browser.waitUntil(
100
+ async () => (await browser.getUrl()).includes(`${BASE}/v1/android.html`),
101
+ { timeoutMsg: 'expected URL to contain /v1/android.html' },
102
+ );
103
+ expect(await (await $('>>>h1')).getText()).toEqual('Android SDK v1');
104
+
105
+ // Sibling child (iOS SDK) should also have moved to the v1 URL, not just this one.
106
+ const iosItem = await page.getSidebarTreeItem('iOS SDK');
107
+ const iosTile = await page.getTreeItemTile(iosItem);
108
+ const iosAnchor = await iosTile.shadow$('a');
109
+ expect(await iosAnchor.getAttribute('href')).toContain('/v1/ios.html');
110
+ });
111
+
112
+ // TODO: known gap — navigating directly to a different child version URL doesn't sync the
113
+ // parent's picker or sibling hrefs back to that version; both stay stuck on the version the
114
+ // page first loaded with. Enable once that reverse sync ships.
115
+ it.skip('should update the picker and sibling hrefs when navigating directly to a different child version', async () => {
116
+ page = new TopicVersionPage(`${BASE}/v1/android.html`);
117
+ page.open();
118
+ await page.waitForMarkdownTopicLoad();
119
+
120
+ page = new TopicVersionPage(`${BASE}/android.html`);
121
+ page.open();
122
+ await page.waitForMarkdownTopicLoad();
123
+
124
+ const picker = await page.getSidebarPicker('Client SDKs');
125
+ expect(await page.getSelectedVersionLabel(picker!)).toEqual("WI'26");
126
+
127
+ const iosItem = await page.getSidebarTreeItem('iOS SDK');
128
+ const iosTile = await page.getTreeItemTile(iosItem);
129
+ const iosAnchor = await iosTile.shadow$('a');
130
+ const href = await iosAnchor.getAttribute('href');
131
+ expect(href).toContain('/ios.html');
132
+ expect(href).not.toContain('/v1/');
133
+ });
134
+ });
135
+
136
+ describe('Topic-level versioning > versioned parent with own content, subtopics and a spec subtopic', () => {
137
+ // fixture: Errors and Rate Limits — latest v2 'SP'26' / older v1 'WI'25'.
138
+ beforeEach(async () => {
139
+ page = new TopicVersionPage(`${BASE}/errors-and-rate-limits.html`);
140
+ page.open();
141
+ await page.waitForMarkdownTopicLoad();
142
+ });
143
+
144
+ it('should render the picker and default to the latest version', async () => {
145
+ const picker = await page.getSidebarPicker('Errors and Rate Limits');
146
+ expect(picker).not.toBeNull();
147
+ expect(await page.getSelectedVersionLabel(picker!)).toEqual("SP'26");
148
+ });
149
+
150
+ it("should switch the parent's own page content when the version changes", async () => {
151
+ const picker = await page.getSidebarPicker('Errors and Rate Limits');
152
+ await page.selectVersionByLabel(picker!, "WI'25");
153
+ await page.waitForMarkdownTopicLoad();
154
+
155
+ expect(await (await $('>>>h1')).getText()).toEqual('Errors and Rate Limits (v1)');
156
+ await browser.waitUntil(
157
+ async () => (await browser.getUrl()).includes(`${BASE}/v1/errors-and-rate-limits.html`),
158
+ { timeoutMsg: 'expected URL to contain /v1/errors-and-rate-limits.html' },
159
+ );
160
+ });
161
+
162
+ it('should switch the markdown subtopics when the version changes', async () => {
163
+ const picker = await page.getSidebarPicker('Errors and Rate Limits');
164
+ await page.selectVersionByLabel(picker!, "WI'25");
165
+ await page.waitForMarkdownTopicLoad();
166
+
167
+ const child = await page.getSidebarTreeItem('Common Error Codes');
168
+ await child.click();
169
+ await page.waitForMarkdownTopicLoad();
170
+
171
+ expect(await (await $('>>>h1')).getText()).toEqual('Common Error Codes (v1)');
172
+ await browser.waitUntil(
173
+ async () => (await browser.getUrl()).includes(`${BASE}/v1/extra-child-a.html`),
174
+ { timeoutMsg: 'expected URL to contain /v1/extra-child-a.html' },
175
+ );
176
+ });
177
+
178
+ it('should switch the spec (Redoc) subtopic to the matching version when the version changes', async () => {
179
+ // Key cross-type cascade: parent v1 ⇒ the nested spec subtopic is v1 too.
180
+ const picker = await page.getSidebarPicker('Errors and Rate Limits');
181
+ await page.selectVersionByLabel(picker!, "WI'25");
182
+ await page.waitForMarkdownTopicLoad();
183
+
184
+ const child = await page.getSidebarTreeItem('Another Spec API');
185
+ await child.click();
186
+ await page.waitForRedocTopicLoad();
187
+
188
+ await browser.waitUntil(
189
+ async () => (await browser.getUrl()).includes(`${BASE}/v1/new-sample-api`),
190
+ { timeoutMsg: 'expected URL to contain /v1/new-sample-api' },
191
+ );
192
+ // v1's spec lacks the `GET /errors/{code}` operation that only v2 has — its absence
193
+ // confirms the v1 spec asset loaded, since both versions share the same spec title.
194
+ const getErrorCodeOperation = await $('a[href*="operation/getErrorCode"]');
195
+ expect(await getErrorCodeOperation.isExisting()).toBeFalsy();
196
+ });
197
+
198
+ // TODO: enable once redoc→parent version-sync ships — the elements already exist, only the
199
+ // sync behavior is unimplemented.
200
+ it.skip('should sync the parent topic version when the version is changed on the nested Redoc page', async () => {
201
+ const child = await page.getSidebarTreeItem('Another Spec API');
202
+ await child.click();
203
+ await page.waitForRedocTopicLoad();
204
+
205
+ const redocPicker = await page.getRedocPicker();
206
+ await page.selectVersionByLabel(redocPicker!, "WI'25");
207
+ await page.waitForRedocTopicLoad();
208
+
209
+ page = new TopicVersionPage(`${BASE}/errors-and-rate-limits.html`);
210
+ page.open();
211
+ await page.waitForMarkdownTopicLoad();
212
+
213
+ const parentPicker = await page.getSidebarPicker('Errors and Rate Limits');
214
+ expect(await page.getSelectedVersionLabel(parentPicker!)).toEqual("WI'25");
215
+ });
216
+ });
217
+
218
+ describe('Topic-level versioning > standalone Redoc reference', () => {
219
+ // fixture: Widgets API Reference — a spec (forward-arrow) topic, so it never gets a sidebar
220
+ // picker; the picker lives on the Redoc page's own project header instead.
221
+ beforeEach(async () => {
222
+ page = new TopicVersionPage(`${BASE}/widgets-api-reference`);
223
+ page.open();
224
+ await page.waitForRedocTopicLoad();
225
+ });
226
+
227
+ it('should render the Redoc version picker and default to the latest version', async () => {
228
+ const picker = await page.getRedocPicker();
229
+ expect(picker).not.toBeNull();
230
+ expect(await page.getSelectedVersionLabel(picker!)).toEqual("SU'26");
231
+ });
232
+
233
+ it('should switch the rendered spec when a different version is selected', async () => {
234
+ const picker = await page.getRedocPicker();
235
+ await page.selectVersionByLabel(picker!, "SP'26");
236
+ await page.waitForRedocTopicLoad();
237
+
238
+ await browser.waitUntil(
239
+ async () => (await browser.getUrl()).includes(`${BASE}/v1/widgets-api-reference`),
240
+ { timeoutMsg: 'expected URL to contain /v1/widgets-api-reference' },
241
+ );
242
+ const apiInfo = await $('>>>div.api-info');
243
+ expect(await apiInfo.getText()).toContain('1.0.0');
244
+ });
245
+
246
+ it('should show the "Newer Version Available" banner when the version is not latest', async () => {
247
+ page = new TopicVersionPage(`${BASE}/v1/widgets-api-reference`);
248
+ page.open();
249
+ await page.waitForRedocTopicLoad();
250
+
251
+ const banner = await page.getRedocVersionBanner();
252
+ expect(banner).not.toBeNull();
253
+ expect(await page.getBannerTitle(banner!)).toEqual('Newer Version Available');
254
+ expect(await page.getBannerLinkHref(banner!)).toContain(`${BASE}/widgets-api-reference`);
255
+ });
256
+
257
+ it('should not show the "Newer Version Available" banner when the version is latest', async () => {
258
+ const banner = await page.getRedocVersionBanner();
259
+ expect(banner).toBeNull();
260
+ });
261
+ });
262
+
263
+ describe('Topic-level versioning > URL resolution (markdown)', () => {
264
+ it('should serve the latest version at the canonical URL', async () => {
265
+ page = new TopicVersionPage(`${BASE}/oauth.html`);
266
+ page.open();
267
+ await page.waitForMarkdownTopicLoad();
268
+ expect(await (await $('>>>h1')).getText()).toEqual('OAuth 2.0');
269
+ });
270
+
271
+ it('should serve the latest version at its versioned alias URL', async () => {
272
+ page = new TopicVersionPage(`${BASE}/v2/oauth.html`);
273
+ page.open();
274
+ await page.waitForMarkdownTopicLoad();
275
+ expect(await (await $('>>>h1')).getText()).toEqual('OAuth 2.0');
276
+ });
277
+
278
+ it('should serve an older version at its versioned URL', async () => {
279
+ page = new TopicVersionPage(`${BASE}/v1/oauth.html`);
280
+ page.open();
281
+ await page.waitForMarkdownTopicLoad();
282
+ expect(await (await $('>>>h1')).getText()).toEqual('OAuth 1.0');
283
+ });
284
+
285
+ it('should initialize the picker from the URL version', async () => {
286
+ page = new TopicVersionPage(`${BASE}/v1/oauth.html`);
287
+ page.open();
288
+ await page.waitForMarkdownTopicLoad();
289
+
290
+ const picker = await page.getSidebarPicker('OAuth');
291
+ expect(await page.getSelectedVersionLabel(picker!)).toEqual("SU'26");
292
+ });
293
+ });
294
+
295
+ describe('Topic-level versioning > URL resolution (spec)', () => {
296
+ it('should serve the latest spec at the canonical URL', async () => {
297
+ page = new TopicVersionPage(`${BASE}/widgets-api-reference`);
298
+ page.open();
299
+ await page.waitForRedocTopicLoad();
300
+ expect(await (await $('>>>div.api-info')).getText()).toContain('2.0.0');
301
+ });
302
+
303
+ it('should serve the latest spec at its versioned alias URL', async () => {
304
+ page = new TopicVersionPage(`${BASE}/v2/widgets-api-reference`);
305
+ page.open();
306
+ await page.waitForRedocTopicLoad();
307
+ expect(await (await $('>>>div.api-info')).getText()).toContain('2.0.0');
308
+ });
309
+
310
+ it('should serve an older spec at its versioned URL', async () => {
311
+ page = new TopicVersionPage(`${BASE}/v1/widgets-api-reference`);
312
+ page.open();
313
+ await page.waitForRedocTopicLoad();
314
+ expect(await (await $('>>>div.api-info')).getText()).toContain('1.0.0');
315
+ });
316
+ });
317
+
318
+ describe('Topic-level versioning > single-version picker (read-only)', () => {
319
+ // A topic with exactly one authored version still renders a picker, but read-only (no
320
+ // dropdown) and never with the "Newer Version Available" banner, since a single version is
321
+ // always the latest by definition.
322
+ // fixture: Widgets API (Beta) — single version 'Beta'.
323
+ it('should render a read-only picker with no dropdown when a Redoc spec has a single version', async () => {
324
+ page = new TopicVersionPage(`${BASE}/widgets-api-beta`);
325
+ page.open();
326
+ await page.waitForRedocTopicLoad();
327
+ const picker = await page.getRedocPicker();
328
+ expect(await page.isReadOnly(picker!)).toBeTruthy();
329
+ expect(await page.getRedocVersionBanner()).toBeNull();
330
+ });
331
+
332
+ // fixture: Link Between Topics — already single version ('WI'26'). TODO: enable once
333
+ // markdown topics render a read-only picker for single-version entries (the markdown
334
+ // counterpart to the Redoc case above).
335
+ it.skip('should render a read-only picker with no dropdown when a markdown topic has a single version', async () => {
336
+ page = new TopicVersionPage(`${BASE}/internal-linking.html`);
337
+ page.open();
338
+ await page.waitForMarkdownTopicLoad();
339
+ const picker = await page.getSidebarPicker('Link Between Topics');
340
+ expect(await page.isReadOnly(picker!)).toBeTruthy();
341
+ expect(await page.getMarkdownVersionBanner()).toBeNull();
342
+ });
343
+ });
344
+
345
+ describe('Topic-level versioning > topics without a version picker', () => {
346
+ it('should not render a sidebar picker when the markdown topic is evergreen', async () => {
347
+ page = new TopicVersionPage(`${BASE}/get-started.html`);
348
+ page.open();
349
+ await page.waitForMarkdownTopicLoad();
350
+
351
+ const picker = await page.getSidebarPicker('Get Started');
352
+ expect(picker).toBeNull();
353
+ expect(await browser.getUrl()).not.toMatch(/\/v\d+\//);
354
+ expect(await page.getMarkdownVersionBanner()).toBeNull();
355
+ });
356
+
357
+ // fixture: Widgets Status API — plain spec:, no versions: at all.
358
+ it('should not render a picker when the Redoc spec is evergreen', async () => {
359
+ page = new TopicVersionPage(`${BASE}/widgets-status-api`);
360
+ page.open();
361
+ await page.waitForRedocTopicLoad();
362
+ expect(await page.getRedocPicker()).toBeNull();
363
+ expect(await page.getRedocVersionBanner()).toBeNull();
364
+ });
365
+
366
+ it('should show the picker only on versioned rows when the sidebar is mixed', async () => {
367
+ page = new TopicVersionPage(`${BASE}/get-started.html`);
368
+ page.open();
369
+ await page.waitForMarkdownTopicLoad();
370
+ await page.waitForSidebar();
371
+
372
+ expect(await page.getSidebarPicker('OAuth')).not.toBeNull();
373
+ expect(await page.getSidebarPicker('Errors and Rate Limits')).not.toBeNull();
374
+ expect(await page.getSidebarPicker('Client SDKs')).not.toBeNull();
375
+ expect(await page.getSidebarPicker('Get Started')).toBeNull();
376
+ expect(await page.getSidebarPicker('Widget Concepts')).toBeNull();
377
+ });
378
+
379
+ it('should not leak a version segment when navigating to an evergreen topic', async () => {
380
+ page = new TopicVersionPage(`${BASE}/v1/oauth.html`);
381
+ page.open();
382
+ await page.waitForMarkdownTopicLoad();
383
+
384
+ const getStarted = await page.getSidebarTreeItem('Get Started');
385
+ await getStarted.click();
386
+ await page.waitForMarkdownTopicLoad();
387
+
388
+ expect(await browser.getUrl()).toContain(`${BASE}/get-started.html`);
389
+ expect(await browser.getUrl()).not.toContain('/v1/');
390
+ });
391
+ });
392
+
393
+ describe('Topic-level versioning > persistence on reload', () => {
394
+ it('should keep the selected version and its content after a refresh', async () => {
395
+ page = new TopicVersionPage(`${BASE}/oauth.html`);
396
+ page.open();
397
+ await page.waitForMarkdownTopicLoad();
398
+
399
+ const picker = await page.getSidebarPicker('OAuth');
400
+ await page.selectVersionByLabel(picker!, "SU'26");
401
+ await page.waitForMarkdownTopicLoad();
402
+
403
+ await browser.refresh();
404
+ await page.waitForMarkdownTopicLoad();
405
+
406
+ const pickerAfterReload = await page.getSidebarPicker('OAuth');
407
+ expect(await page.getSelectedVersionLabel(pickerAfterReload!)).toEqual("SU'26");
408
+ expect(await (await $('>>>h1')).getText()).toEqual('OAuth 1.0');
409
+ expect(await browser.getUrl()).toContain(`${BASE}/v1/oauth.html`);
410
+
411
+ const selections = await page.readSessionVersionSelections();
412
+ expect(selections).not.toBeNull();
413
+ });
414
+ });
415
+
416
+ describe('Topic-level versioning > invalid version in URL', () => {
417
+ it('should return a 404 when the markdown URL has an invalid version', async () => {
418
+ browser.url(`${BASE}/v999/oauth.html`);
419
+
420
+ const errorComponent = await $('dx-error');
421
+ await errorComponent.waitForDisplayed();
422
+ expect(await browser.getTitle()).toEqual('404 Error | Salesforce Developers');
423
+ });
424
+
425
+ it('should return a 404 when the spec URL has an invalid version', async () => {
426
+ browser.url(`${BASE}/v999/widgets-api-reference`);
427
+
428
+ const errorComponent = await $('dx-error');
429
+ await errorComponent.waitForDisplayed();
430
+ expect(await browser.getTitle()).toEqual('404 Error | Salesforce Developers');
431
+ });
432
+ });
@@ -1,101 +0,0 @@
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();