@salesforcedevs/sfdocs-e2e 1.0.11 → 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",
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": {
@@ -40,6 +40,90 @@ describe('Test Sitemap Routes', () => {
40
40
  );
41
41
  });
42
42
 
43
+ it('lists each docs markdown page once and each spec page without a .html extension', async () => {
44
+ await HomePage.open('sitemap.xml');
45
+ const data = await browser.getSitemap(
46
+ 'http://localhost:3000/internal-sitemap-contents-1.xml',
47
+ );
48
+
49
+ // Markdown topics are published with a .html extension.
50
+ expect(data.body).toContain('<loc>/docs/docs-content-type-example/get-started.html</loc>');
51
+ expect(data.body).toContain(
52
+ '<loc>/docs/docs-content-type-example/widget-concepts.html</loc>',
53
+ );
54
+ expect(data.body).toContain(
55
+ '<loc>/docs/docs-content-type-example/beta-bulk-import.html</loc>',
56
+ );
57
+
58
+ // Spec (Redoc) topics are published without an extension.
59
+ expect(data.body).toContain('<loc>/docs/docs-content-type-example/sample-api</loc>');
60
+ expect(data.body).toContain('<loc>/docs/docs-content-type-example/new-sample-api</loc>');
61
+
62
+ // Each markdown URL appears exactly once.
63
+ const occurrences =
64
+ data.body.split('<loc>/docs/docs-content-type-example/get-started.html</loc>').length -
65
+ 1;
66
+ expect(occurrences).toBe(1);
67
+ });
68
+
69
+ it('excludes the external-link topic from the docs sitemap', async () => {
70
+ await HomePage.open('sitemap.xml');
71
+ const data = await browser.getSitemap(
72
+ 'http://localhost:3000/internal-sitemap-contents-1.xml',
73
+ );
74
+
75
+ // externalLink entries never become routes, so they can't reach the sitemap.
76
+ expect(data.body).not.toContain('developer.salesforce.com');
77
+ });
78
+
79
+ it('lists the deep-nesting docs project pages and spec in the sitemap', async () => {
80
+ await HomePage.open('sitemap.xml');
81
+ const data = await browser.getSitemap(
82
+ 'http://localhost:3000/internal-sitemap-contents-1.xml',
83
+ );
84
+
85
+ expect(data.body).toContain('<loc>/docs/docs-deep-nesting-example/introduction.html</loc>');
86
+ expect(data.body).toContain('<loc>/docs/docs-deep-nesting-example/install.html</loc>');
87
+ expect(data.body).toContain('<loc>/docs/docs-deep-nesting-example/workspace.html</loc>');
88
+ expect(data.body).toContain('<loc>/docs/docs-deep-nesting-example/example-api</loc>');
89
+ });
90
+
91
+ it('lists a localized docs page but omits pages excluded from the ja-jp menu', async () => {
92
+ await HomePage.open('sitemap.xml');
93
+ const data = await browser.getSitemap(
94
+ 'http://localhost:3000/internal-sitemap-contents-1.xml',
95
+ );
96
+
97
+ // ja-jp topics that are configured in the ja-jp TOC are legitimately in the sitemap.
98
+ expect(data.body).toContain(
99
+ '<loc>/docs/ja-jp/docs-content-type-example/get-started.html</loc>',
100
+ );
101
+
102
+ // englishonly pages are never in the ja-jp menu, so they must not appear under /ja-jp/.
103
+ expect(data.body).not.toContain(
104
+ '<loc>/docs/ja-jp/docs-content-type-example/us-compliance-notes.html</loc>',
105
+ );
106
+ });
107
+
108
+ it('serves the noindex robots metatag on the docs home URL but not on a real topic', async () => {
109
+ await browser.addCommand('getHtml', async (url: any) => {
110
+ const data = await got(url);
111
+ return data;
112
+ });
113
+
114
+ // The project home URL renders the first topic as a landing page with noindex.
115
+ const home = await browser.getHtml('http://localhost:3000/docs/docs-content-type-example');
116
+ expect(home.body).toContain('<meta name="robots" content="noindex,nofollow,nosnippet"/>');
117
+
118
+ // The same topic requested at its real URL must be indexable.
119
+ const topic = await browser.getHtml(
120
+ 'http://localhost:3000/docs/docs-content-type-example/get-started.html',
121
+ );
122
+ expect(topic.body).not.toContain(
123
+ '<meta name="robots" content="noindex,nofollow,nosnippet"/>',
124
+ );
125
+ });
126
+
43
127
  it('Read Coveo sitemap.xml', async () => {
44
128
  await browser.addCommand('getCoveoSitemap', async (url: any) => {
45
129
  const data = await got(url, {
@@ -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,6 +1,9 @@
1
+ import DocsPage from '../pageobjects/docs.page';
1
2
  import GuidePage from '../pageobjects/guide.page';
2
3
  import { ReferencePage } from '../pageobjects/reference.page';
3
4
 
5
+ const DOCS_MARKDOWN_PATH = '/docs/docs-content-type-example/get-started.html';
6
+
4
7
  const EN_US_GUIDE_PATH = '/docs/service/vscode-maintainer/guide/vscode-overview.html';
5
8
  const EN_US_MARKDOWN_REFERENCE_PATH =
6
9
  '/docs/commerce/flexible-references/references/shopper-manual-ref/quick-start.html';
@@ -41,6 +44,16 @@ describe('Content action toolbar scenarios', () => {
41
44
  await expectToolbarButtons(toolbar);
42
45
  });
43
46
 
47
+ it('Markdown docs page should have the toolbar', async () => {
48
+ const page = new DocsPage(DOCS_MARKDOWN_PATH);
49
+ page.open();
50
+ await page.waitPageLoad();
51
+
52
+ const toolbar = await $('>>>doc-content-action-toolbar');
53
+ await toolbar.waitForDisplayed();
54
+ await expectToolbarButtons(toolbar);
55
+ });
56
+
44
57
  it("Non-markdown reference shouldn't have toolbar", async () => {
45
58
  const referencePage = new ReferencePage(NON_MARKDOWN_REFERENCE_PATH);
46
59
  referencePage.open();
@@ -290,6 +290,292 @@ describe('Docs content type > locale picker', () => {
290
290
  });
291
291
  });
292
292
 
293
+ describe('Docs content type > localized (ja-jp) rendering', () => {
294
+ it('renders the Japanese body and sidebar on a ja-jp markdown topic', async () => {
295
+ page = new DocsPage('/docs/ja-jp/docs-content-type-example/get-started.html');
296
+ page.open();
297
+ await page.waitPageLoad();
298
+ await page.waitForSidebar();
299
+
300
+ expect(await (await page.getContentHeading()).getText()).toContain(
301
+ 'ウィジェット サービスを始める',
302
+ );
303
+ // Sidebar tiles are localized — the Japanese "Widget Concepts" label resolves.
304
+ const tile = await page.getSidebarTile('ウィジェットの概念');
305
+ expect(await tile.isExisting()).toBe(true);
306
+ });
307
+
308
+ it('renders the Redoc view from a ja-jp spec URL', async () => {
309
+ page = new DocsPage('/docs/ja-jp/docs-content-type-example/sample-api');
310
+ page.open();
311
+ await page.waitForRedocLoad();
312
+
313
+ expect(await browser.getUrl()).toContain('/ja-jp/');
314
+ expect(await (await page.getRedocApiTitle()).getText()).not.toBe('');
315
+ });
316
+ });
317
+
318
+ describe('Docs content type > englishonly filter', () => {
319
+ it('serves the englishonly topic in English', async () => {
320
+ page = new DocsPage('/docs/docs-content-type-example/us-compliance-notes.html');
321
+ page.open();
322
+ await page.waitPageLoad();
323
+
324
+ expect(await (await page.getContentHeading()).getText()).toContain('US Compliance Notes');
325
+ });
326
+ });
327
+
328
+ describe('Docs content type > language picker round-trip', () => {
329
+ it('switches a markdown topic between English and Japanese and back', async () => {
330
+ page = new DocsPage('/docs/docs-content-type-example/get-started.html');
331
+ page.open();
332
+ await page.waitPageLoad();
333
+ await page.waitForSidebar();
334
+
335
+ await page.selectLanguage('日本語');
336
+ await page.waitPageLoad();
337
+ expect(await browser.getUrl()).toContain('/ja-jp/');
338
+ expect(await browser.getUrl()).toContain('get-started.html');
339
+ expect(await (await page.getContentHeading()).getText()).toContain(
340
+ 'ウィジェット サービスを始める',
341
+ );
342
+
343
+ await page.selectLanguage('English');
344
+ await page.waitPageLoad();
345
+ expect(await browser.getUrl()).not.toContain('/ja-jp/');
346
+ expect(await browser.getUrl()).toContain('get-started.html');
347
+ expect(await (await page.getContentHeading()).getText()).toContain('Get Started');
348
+ });
349
+ });
350
+
351
+ describe('Docs content type > external link', () => {
352
+ it('exposes the real href and opens the external link in a new tab', async () => {
353
+ page = new DocsPage('/docs/docs-content-type-example/get-started.html');
354
+ page.open();
355
+ await page.waitPageLoad();
356
+ await page.waitForSidebar();
357
+
358
+ const anchor = await page.getTileAnchor('Salesforce Developers');
359
+ expect(await anchor.getAttribute('href')).toContain('https://developer.salesforce.com');
360
+ expect(await anchor.getAttribute('target')).toBe('_blank');
361
+ });
362
+ });
363
+
364
+ describe('Docs content type > Liquid templating', () => {
365
+ beforeEach(async () => {
366
+ page = new DocsPage('/docs/docs-content-type-example/liquid-widgets.html');
367
+ page.open();
368
+ await page.waitPageLoad();
369
+ });
370
+
371
+ it('resolves frontmatter assigns and liquid.data values in the body', async () => {
372
+ const content = await $('>>>doc-content-layout');
373
+ const text = await content.getText();
374
+ const html = await content.getHTML();
375
+
376
+ expect(text).toContain('Widget Automation');
377
+ expect(text).toContain('Hello from the docs liquid data file');
378
+ expect(html).not.toContain('{% assign');
379
+ expect(html).not.toContain('{{ feature }}');
380
+ expect(html).not.toContain('{{ data.tagline }}');
381
+ });
382
+
383
+ it('resolves repository-level variables from content-config.yml', async () => {
384
+ const content = await $('>>>doc-content-layout');
385
+ const text = await content.getText();
386
+ const html = await content.getHTML();
387
+
388
+ expect(text).toContain('Salesforce Platform');
389
+ expect(text).toContain('62.0');
390
+ expect(html).not.toContain('{{ variables.platform');
391
+ });
392
+
393
+ it('renders a loop over a liquid.data array', async () => {
394
+ const content = await $('>>>doc-content-layout');
395
+ const text = await content.getText();
396
+ const html = await content.getHTML();
397
+
398
+ expect(text).toContain('createWidget()');
399
+ expect(text).toContain('updateWidget()');
400
+ expect(text).toContain('deleteWidget()');
401
+ expect(html).not.toContain('for operation in data.operations');
402
+ });
403
+
404
+ it('resolves output expressions inside code blocks but keeps tags literal', async () => {
405
+ const codeBlockElement = await $('>>>.code-block-content');
406
+ const codeBlock = await codeBlockElement.shadow$('code');
407
+
408
+ expect(await codeBlock.isExisting()).toBeTruthy();
409
+
410
+ const codeText = await codeBlock.getText();
411
+ // {{ }} outputs resolve even inside code blocks.
412
+ expect(codeText).not.toContain('{{ data.tagline }}');
413
+ expect(codeText).toContain('Hello from the docs liquid data file');
414
+ expect(codeText).toContain('Widget Automation');
415
+ // {% %} tags stay literal inside code blocks.
416
+ expect(codeText).toContain('for op in ops');
417
+ });
418
+
419
+ it('renders a shared partial pulled in via include and its capture blocks', async () => {
420
+ const content = await $('>>>doc-content-layout');
421
+ const text = await content.getText();
422
+ const html = await content.getHTML();
423
+
424
+ expect(text).toContain('Reusable Tip');
425
+ expect(text).toContain('Widgets are created and managed through the Widgets API');
426
+ expect(text).toContain('Reusable Note');
427
+ expect(text).toContain('Every widget has a unique identifier');
428
+ // Dash-form tags are the fixture's real (resolved) tags; the bare "{% include %}"
429
+ // in the prose renders as literal inline code, so assert on the dash form only.
430
+ expect(html).not.toContain('{%- include');
431
+ expect(html).not.toContain('{%- capture');
432
+ expect(html).not.toContain('{{ liquid-widgets-reuse-tip }}');
433
+ });
434
+ });
435
+
436
+ describe('Docs content type > Liquid data object format', () => {
437
+ it('loads multiple data files each under a named key', async () => {
438
+ page = new DocsPage('/docs/docs-content-type-example/liquid-multi-data.html');
439
+ page.open();
440
+ await page.waitPageLoad();
441
+
442
+ const content = await $('>>>doc-content-layout');
443
+ const text = await content.getText();
444
+ const html = await content.getHTML();
445
+
446
+ expect(text).toContain('Hello from the docs liquid data file');
447
+ expect(text).toContain('Second data file loaded under a named key');
448
+ expect(html).not.toContain('{{ data.one.tagline }}');
449
+ expect(html).not.toContain('{{ two.summary }}');
450
+ });
451
+ });
452
+
453
+ describe('Docs content type > Liquid templating (ja-jp)', () => {
454
+ let jaLiquidEnabled = false;
455
+ const widgetsPath = '/docs/ja-jp/docs-content-type-example/liquid-widgets.html';
456
+
457
+ before(async () => {
458
+ try {
459
+ page = new DocsPage(widgetsPath);
460
+ page.open();
461
+ await page.waitPageLoad();
462
+
463
+ const content = await $('>>>doc-content-layout');
464
+ const text = await content.getText();
465
+ const html = await content.getHTML();
466
+
467
+ const hasLiquidOutput = text.includes(
468
+ 'ドキュメント Liquid データ ファイルからこんにちは',
469
+ );
470
+ const hasRawSyntax = html.includes('{% assign') || html.includes('{{ data.tagline }}');
471
+
472
+ jaLiquidEnabled = hasLiquidOutput && !hasRawSyntax;
473
+ } catch (e) {
474
+ jaLiquidEnabled = false;
475
+ }
476
+ });
477
+
478
+ beforeEach(async () => {
479
+ if (jaLiquidEnabled) {
480
+ page = new DocsPage(widgetsPath);
481
+ page.open();
482
+ await page.waitPageLoad();
483
+ }
484
+ });
485
+
486
+ it('resolves frontmatter assigns and liquid.data values in a localized topic', async function () {
487
+ if (!jaLiquidEnabled) {
488
+ this.skip();
489
+ }
490
+
491
+ const content = await $('>>>doc-content-layout');
492
+ const text = await content.getText();
493
+ const html = await content.getHTML();
494
+
495
+ expect(text).toContain('ウィジェット オートメーション');
496
+ expect(text).toContain('ドキュメント Liquid データ ファイルからこんにちは');
497
+ expect(html).not.toContain('{% assign');
498
+ expect(html).not.toContain('{{ data.tagline }}');
499
+ });
500
+
501
+ it('resolves repository-level variables in a localized topic', async function () {
502
+ if (!jaLiquidEnabled) {
503
+ this.skip();
504
+ }
505
+
506
+ const content = await $('>>>doc-content-layout');
507
+ const text = await content.getText();
508
+ const html = await content.getHTML();
509
+
510
+ expect(text).toContain('Salesforce Platform');
511
+ expect(text).toContain('62.0');
512
+ expect(html).not.toContain('{{ variables.platform');
513
+ });
514
+
515
+ it('renders a loop and an included partial capture block in a localized topic', async function () {
516
+ if (!jaLiquidEnabled) {
517
+ this.skip();
518
+ }
519
+
520
+ const content = await $('>>>doc-content-layout');
521
+ const text = await content.getText();
522
+ const html = await content.getHTML();
523
+
524
+ expect(text).toContain('createWidget()');
525
+ expect(text).toContain('再利用可能なヒント');
526
+ expect(text).toContain('ウィジェットは Widgets API を通じて作成および管理されます');
527
+ expect(html).not.toContain('for operation in data.operations');
528
+ expect(html).not.toContain('{%- include');
529
+ expect(html).not.toContain('{%- capture');
530
+ });
531
+ });
532
+
533
+ describe('Docs content type > Liquid data object format (ja-jp)', () => {
534
+ let jaLiquidEnabled = false;
535
+ const multiDataPath = '/docs/ja-jp/docs-content-type-example/liquid-multi-data.html';
536
+
537
+ before(async () => {
538
+ try {
539
+ page = new DocsPage(multiDataPath);
540
+ page.open();
541
+ await page.waitPageLoad();
542
+
543
+ const content = await $('>>>doc-content-layout');
544
+ const text = await content.getText();
545
+ const html = await content.getHTML();
546
+
547
+ const hasLiquidOutput = text.includes(
548
+ 'ドキュメント Liquid データ ファイルからこんにちは',
549
+ );
550
+ const hasRawSyntax =
551
+ html.includes('{{ data.one.tagline }}') || html.includes('{{ two.summary }}');
552
+
553
+ jaLiquidEnabled = hasLiquidOutput && !hasRawSyntax;
554
+ } catch (e) {
555
+ jaLiquidEnabled = false;
556
+ }
557
+ });
558
+
559
+ it('loads multiple data files each under a named key in a localized topic', async function () {
560
+ if (!jaLiquidEnabled) {
561
+ this.skip();
562
+ }
563
+
564
+ page = new DocsPage(multiDataPath);
565
+ page.open();
566
+ await page.waitPageLoad();
567
+
568
+ const content = await $('>>>doc-content-layout');
569
+ const text = await content.getText();
570
+ const html = await content.getHTML();
571
+
572
+ expect(text).toContain('ドキュメント Liquid データ ファイルからこんにちは');
573
+ expect(text).toContain('名前付きキーの下に読み込まれる 2 番目のデータ ファイル');
574
+ expect(html).not.toContain('{{ data.one.tagline }}');
575
+ expect(html).not.toContain('{{ two.summary }}');
576
+ });
577
+ });
578
+
293
579
  describe('Docs content type > error routes', () => {
294
580
  const expectNotFoundPage = async (): Promise<void> => {
295
581
  const errorComponent = await $('dx-error');
@@ -1,3 +1,4 @@
1
+ import DocsPage from '../pageobjects/docs.page';
1
2
  import GuidePage from '../pageobjects/guide.page';
2
3
 
3
4
  let page: GuidePage;
@@ -54,3 +55,56 @@ describe('Liquid Template Rendering — Advanced', () => {
54
55
  expect(text.includes('previewFuture')).toBeTruthy();
55
56
  });
56
57
  });
58
+
59
+ describe('Docs content type > future filter', () => {
60
+ let docsFutureEnabled = false;
61
+ const overviewPath = '/docs/docs-content-type-example/get-started.html';
62
+
63
+ before(async () => {
64
+ try {
65
+ const docsPage = new DocsPage(overviewPath);
66
+ docsPage.open();
67
+ await docsPage.waitPageLoad();
68
+
69
+ const content = await $('>>>doc-content-layout');
70
+ const text = await content.getText();
71
+
72
+ docsFutureEnabled = text.includes(
73
+ 'hidden in production and excluded from localization unless',
74
+ );
75
+ } catch (e) {
76
+ docsFutureEnabled = false;
77
+ }
78
+ });
79
+
80
+ it('renders an inline future block when previewFuture is enabled', async function () {
81
+ if (!docsFutureEnabled) {
82
+ this.skip();
83
+ }
84
+
85
+ const docsPage = new DocsPage(overviewPath);
86
+ docsPage.open();
87
+ await docsPage.waitPageLoad();
88
+
89
+ const content = await $('>>>doc-content-layout');
90
+ const text = await content.getText();
91
+ const html = await content.getHTML();
92
+
93
+ expect(text).toContain('hidden in production and excluded from localization unless');
94
+ expect(html).not.toContain('{% future');
95
+ });
96
+
97
+ it('renders the future topic page when previewFuture is enabled', async function () {
98
+ if (!docsFutureEnabled) {
99
+ this.skip();
100
+ }
101
+
102
+ const docsPage = new DocsPage('/docs/docs-content-type-example/upcoming-graphql.html');
103
+ docsPage.open();
104
+ await docsPage.waitPageLoad();
105
+
106
+ expect(await (await docsPage.getContentHeading()).getText()).toContain(
107
+ 'Upcoming: GraphQL API',
108
+ );
109
+ });
110
+ });
@@ -222,3 +222,100 @@ describe('Liquid Template Rendering', () => {
222
222
  expect(text.includes('Liquid is a templating language supported in SFDocs')).toBeTruthy();
223
223
  });
224
224
  });
225
+
226
+ describe('Liquid Template Rendering — Localized (ja-jp)', () => {
227
+ let jaLiquidEnabled = false;
228
+ const quickstartPath = '/docs/ja-jp/liquid-project/guide/liquid-quickstart.html';
229
+
230
+ before(async () => {
231
+ try {
232
+ page = new GuidePage(quickstartPath);
233
+ await browser.url(page.path);
234
+ await page.waitPageLoad();
235
+
236
+ const content = await $('>>>doc-content-layout');
237
+ const text = await content.getText();
238
+ const html = await content.getHTML();
239
+
240
+ const hasLiquidOutput = text.includes('data.yaml からこんにちは');
241
+ const hasRawSyntax =
242
+ html.includes('{% assign topic') || html.includes('{{ data.greeting }}');
243
+
244
+ jaLiquidEnabled = hasLiquidOutput && !hasRawSyntax;
245
+ } catch (e) {
246
+ jaLiquidEnabled = false;
247
+ }
248
+ });
249
+
250
+ beforeEach(async () => {
251
+ if (jaLiquidEnabled) {
252
+ page = new GuidePage(quickstartPath);
253
+ await browser.url(page.path);
254
+ await page.waitPageLoad();
255
+ }
256
+ });
257
+
258
+ it('Should render frontmatter assigns and data.yaml values in Japanese', async function () {
259
+ if (!jaLiquidEnabled) {
260
+ this.skip();
261
+ }
262
+
263
+ const content = await $('>>>doc-content-layout');
264
+ const text = await content.getText();
265
+
266
+ expect(text.includes('ライターのための GitHub')).toBeTruthy();
267
+ expect(text.includes('data.yaml からこんにちは')).toBeTruthy();
268
+ });
269
+
270
+ it('Should render repository-level variables in a localized topic', async function () {
271
+ if (!jaLiquidEnabled) {
272
+ this.skip();
273
+ }
274
+
275
+ const content = await $('>>>doc-content-layout');
276
+ const text = await content.getText();
277
+ const html = await content.getHTML();
278
+
279
+ expect(text.includes('Salesforce Platform')).toBeTruthy();
280
+ expect(text.includes('62.0')).toBeTruthy();
281
+ expect(html.includes('{{ variables.platform')).toBeFalsy();
282
+ });
283
+
284
+ it('Should render a loop and an included partial capture block in Japanese', async function () {
285
+ if (!jaLiquidEnabled) {
286
+ this.skip();
287
+ }
288
+
289
+ const content = await $('>>>doc-content-layout');
290
+ const text = await content.getText();
291
+ const html = await content.getHTML();
292
+
293
+ expect(text.includes('create()')).toBeTruthy();
294
+ expect(text.includes('再利用可能なヒント')).toBeTruthy();
295
+ expect(
296
+ text.includes('ウィジェットは Widgets API を通じて作成および管理されます'),
297
+ ).toBeTruthy();
298
+ // Dash-form tags are the fixture's real (resolved) tags; the bare "{% include %}"
299
+ // in the prose renders as literal inline code, so assert on the dash form only.
300
+ expect(html.includes('{%- include')).toBeFalsy();
301
+ expect(html.includes('{%- capture')).toBeFalsy();
302
+ });
303
+
304
+ it('Should render liquid.data object format in a localized topic', async function () {
305
+ if (!jaLiquidEnabled) {
306
+ this.skip();
307
+ }
308
+
309
+ const multiDataPage = new GuidePage('/docs/ja-jp/liquid-project/guide/multi-data.html');
310
+ await browser.url(multiDataPage.path);
311
+ await multiDataPage.waitPageLoad();
312
+
313
+ const content = await $('>>>doc-content-layout');
314
+ const text = await content.getText();
315
+ const html = await content.getHTML();
316
+
317
+ expect(text.includes('小売店舗や顧客サイト')).toBeTruthy();
318
+ expect(html.includes('{{ data.one.summary }}')).toBeFalsy();
319
+ expect(html.includes('{{ two.summary }}')).toBeFalsy();
320
+ });
321
+ });
@@ -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
+ });