From ecd444fdfe88d7be3f0800f8e5da74e9fc7d7cbb Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Thu, 13 Aug 2026 10:38:56 -0700 Subject: [PATCH 1/6] fix(browse): respect editor.hidePublish config in browse action bar The bulk Publish action in the browse view's action bar had no awareness of the editor.hidePublish org/site config, unlike the edit view's da-title.js. Also switches editor.hidePublish/editor.path reads to look up the config sheet by name ("data") first, falling back to positional lookup, since getFirstSheet silently picks the wrong sheet when the data rows aren't the first key in a multi-sheet config. Co-Authored-By: Claude Sonnet 5 --- blocks/browse/da-actionbar/da-actionbar.js | 14 ++++++- blocks/browse/da-browse/da-browse.js | 12 +++++- blocks/browse/da-list/da-list.js | 2 + blocks/edit/da-title/da-title.js | 7 +++- .../browse/da-actionbar/da-actionbar.test.js | 38 +++++++++++++++++ .../blocks/browse/da-browse/da-browse.test.js | 41 +++++++++++++++++++ .../blocks/edit/da-title/da-title.test.js | 31 ++++++++++++++ 7 files changed, 140 insertions(+), 5 deletions(-) diff --git a/blocks/browse/da-actionbar/da-actionbar.js b/blocks/browse/da-actionbar/da-actionbar.js index 1460dee65..14b0b3f2d 100644 --- a/blocks/browse/da-actionbar/da-actionbar.js +++ b/blocks/browse/da-actionbar/da-actionbar.js @@ -27,6 +27,7 @@ export default class DaActionBar extends LitElement { loading: { attribute: false }, isFavorite: { attribute: false }, isHlx6: { attribute: false }, + hidePublishConfs: { attribute: false }, _isCopying: { state: true }, _isDeleting: { state: true }, _isMoving: { state: true }, @@ -38,6 +39,7 @@ export default class DaActionBar extends LitElement { this.items = []; this.currentPath = ''; this.isFavorite = false; + this.hidePublishConfs = []; } connectedCallback() { @@ -141,6 +143,16 @@ export default class DaActionBar extends LitElement { return this._canWrite && this.items.some((item) => item.ext && item.ext !== 'link') && !this._isCopying; } + get _hidePublish() { + return this.hidePublishConfs.some( + (prefix) => this.items.some((item) => item.path?.startsWith(prefix)), + ); + } + + get _canPublish() { + return this._canAemAction && !this._hidePublish; + } + get _canRename() { if (!this._canWrite) return false; const isFolder = !this.items[0]?.ext; @@ -225,7 +237,7 @@ export default class DaActionBar extends LitElement { diff --git a/blocks/browse/da-browse/da-browse.js b/blocks/browse/da-browse/da-browse.js index 4a18e8db8..3f8d54926 100644 --- a/blocks/browse/da-browse/da-browse.js +++ b/blocks/browse/da-browse/da-browse.js @@ -1,5 +1,7 @@ import { LitElement, html, nothing } from 'da-lit'; -import { getFirstSheet, fetchDaConfigs } from '../../shared/utils.js'; +import { + getSheetByName, getFirstSheet, fetchDaConfigs, +} from '../../shared/utils.js'; import { getNx, sanitizePathParts, getNxEWFlags } from '../../../scripts/utils.js'; import { getChatPanelContent } from '../../shared/chat-panel.js'; @@ -161,11 +163,16 @@ export default class DaBrowse extends LitElement { if (reFetch) { const { org, site } = this.details; const configs = await Promise.all(fetchDaConfigs({ org, site })); - const rows = configs.filter(Boolean).reverse().flatMap((c) => getFirstSheet(c) || []); + const rows = configs.filter(Boolean).reverse() + .flatMap((c) => getSheetByName(c, 'data') ?? getFirstSheet(c) ?? []); this.editorConfs = rows.reduce((acc, row) => { if (row.key === 'editor.path') acc.push(row.value); return acc; }, []); + this.hidePublishConfs = rows.reduce((acc, row) => { + if (row.key === 'editor.hidePublish') acc.push(row.value); + return acc; + }, []); } if (!this.editorConfs || this.editorConfs.length === 0) return DEF_EDIT; @@ -236,6 +243,7 @@ export default class DaBrowse extends LitElement { class="da-list-type-${type}" fullpath="${fullpath}" editor="${this.editor}" + .hidePublishConfs=${this.hidePublishConfs} @onpermissions=${this.handlePermissions} @selectionchanged=${type === 'browse' && this._chatEnabled ? this._handleBrowseSelection : nothing} select="${select ? true : nothing}" diff --git a/blocks/browse/da-list/da-list.js b/blocks/browse/da-list/da-list.js index 9e4c7f196..66daf6fbc 100644 --- a/blocks/browse/da-list/da-list.js +++ b/blocks/browse/da-list/da-list.js @@ -17,6 +17,7 @@ export default class DaList extends LitElement { listtype: { type: String }, fullpath: { type: String }, editor: { type: String }, + hidePublishConfs: { attribute: false }, select: { type: Boolean }, sort: { type: Boolean }, drag: { type: Boolean }, @@ -1252,6 +1253,7 @@ export default class DaList extends LitElement { @onpublish=${this.handlePublish} @onshare=${this.handleShare} .loading=${typeof this._aemActionState === 'string' ? this._aemActionState : null} + .hidePublishConfs=${this.hidePublishConfs} currentPath="${this.fullpath}" .isHlx6=${this._isHlx6 ?? false} role="row" diff --git a/blocks/edit/da-title/da-title.js b/blocks/edit/da-title/da-title.js index baa34ae1a..62565a87d 100644 --- a/blocks/edit/da-title/da-title.js +++ b/blocks/edit/da-title/da-title.js @@ -5,7 +5,9 @@ import { saveDaConfig, getAemHrefs, } from '../utils/helpers.js'; -import { delay, fetchDaConfigs, getFirstSheet, aemAction, saveDaVersion } from '../../shared/utils.js'; +import { + delay, fetchDaConfigs, getSheetByName, getFirstSheet, aemAction, saveDaVersion, +} from '../../shared/utils.js'; import inlinesvg from '../../shared/inlinesvg.js'; import getSheet from '../../shared/sheet.js'; @@ -113,7 +115,8 @@ export default class DaTitle extends LitElement { async filterActions() { const { org, site, fullpath } = this.details; const configs = await Promise.all(fetchDaConfigs({ org, site })); - const configTab = configs.flatMap((config) => getFirstSheet(config) || []); + const configTab = configs.filter(Boolean) + .flatMap((config) => getSheetByName(config, 'data') ?? getFirstSheet(config) ?? []); // Check which actions should be allowed for the document based on config const publishConfigs = configTab.filter((c) => c.key === 'editor.hidePublish'); diff --git a/test/unit/blocks/browse/da-actionbar/da-actionbar.test.js b/test/unit/blocks/browse/da-actionbar/da-actionbar.test.js index b848e2e88..c0152af04 100644 --- a/test/unit/blocks/browse/da-actionbar/da-actionbar.test.js +++ b/test/unit/blocks/browse/da-actionbar/da-actionbar.test.js @@ -126,6 +126,44 @@ describe('DaActionBar', () => { }); }); + describe('_hidePublish / _canPublish', () => { + it('_hidePublish is false when hidePublishConfs is empty', () => { + const el = new DaActionBar(); + el.items = [{ ext: 'html', path: '/org/site/blog/page.html' }]; + expect(el._hidePublish).to.be.false; + }); + + it('_hidePublish is true when a selected item path matches a prefix', () => { + const el = new DaActionBar(); + el.items = [{ ext: 'html', path: '/org/site/blog/page.html' }]; + el.hidePublishConfs = ['/org/site/blog']; + expect(el._hidePublish).to.be.true; + }); + + it('_hidePublish is false when no selected item path matches any prefix', () => { + const el = new DaActionBar(); + el.items = [{ ext: 'html', path: '/org/site/news/page.html' }]; + el.hidePublishConfs = ['/org/site/blog']; + expect(el._hidePublish).to.be.false; + }); + + it('_canPublish is false when hidden, even with write permission', () => { + const el = new DaActionBar(); + el.permissions = ['read', 'write']; + el.items = [{ ext: 'html', path: '/org/site/blog/page.html' }]; + el.hidePublishConfs = ['/org/site/blog']; + expect(el._canPublish).to.be.false; + }); + + it('_canPublish is true when not hidden and _canAemAction is true', () => { + const el = new DaActionBar(); + el.permissions = ['read', 'write']; + el.items = [{ ext: 'html', path: '/org/site/news/page.html' }]; + el.hidePublishConfs = ['/org/site/blog']; + expect(el._canPublish).to.be.true; + }); + }); + describe('_canShare', () => { it('Returns false when no items have a non-link extension', () => { const el = new DaActionBar(); diff --git a/test/unit/blocks/browse/da-browse/da-browse.test.js b/test/unit/blocks/browse/da-browse/da-browse.test.js index 9d01eb4f9..631b49633 100644 --- a/test/unit/blocks/browse/da-browse/da-browse.test.js +++ b/test/unit/blocks/browse/da-browse/da-browse.test.js @@ -398,6 +398,47 @@ describe('DaBrowse Component', () => { const url = await daBrowseComp.getEditor(true); expect(url).to.equal('/edit#'); }); + + // Scope mocks to the site-level config URL only (org-level gets an empty + // config) since fetchDaConfigs fetches org + site in parallel, and a + // catch-all mock would return the same rows for both, doubling entries. + function mockSiteConfig(fullpath, json) { + window.fetch = async (url) => { + if (url.includes(`/config${fullpath}/`)) return { ok: true, json: async () => json }; + return { ok: true, json: async () => ({ data: [] }) }; + }; + } + + it('collects editor.hidePublish rows into hidePublishConfs', async () => { + daBrowseComp.details = { fullpath: '/myorg-d/mysite/folder', org: 'myorg-d', site: 'mysite', owner: 'myorg-d', depth: 3 }; + mockSiteConfig('/myorg-d/mysite', { + data: [ + { key: 'editor.path', value: '/myorg-d/mysite=https://da.live/form#' }, + { key: 'editor.hidePublish', value: '/myorg-d/mysite/blog' }, + ], + }); + await daBrowseComp.getEditor(true); + expect(daBrowseComp.hidePublishConfs).to.deep.equal(['/myorg-d/mysite/blog']); + }); + + it('collects editor.hidePublish rows living in a non-first multi-sheet tab named "data"', async () => { + daBrowseComp.details = { fullpath: '/myorg-e/mysite/folder', org: 'myorg-e', site: 'mysite', owner: 'myorg-e', depth: 3 }; + mockSiteConfig('/myorg-e/mysite', { + permissions: { data: [{ path: '/', groups: 'everyone', actions: 'write' }] }, + data: { data: [{ key: 'editor.hidePublish', value: '/myorg-e/mysite/blog' }] }, + ':names': ['permissions', 'data'], + ':type': 'multi-sheet', + }); + await daBrowseComp.getEditor(true); + expect(daBrowseComp.hidePublishConfs).to.deep.equal(['/myorg-e/mysite/blog']); + }); + + it('returns an empty hidePublishConfs when no editor.hidePublish rows exist', async () => { + daBrowseComp.details = { fullpath: '/myorg-f/mysite/folder', org: 'myorg-f', site: 'mysite', owner: 'myorg-f', depth: 3 }; + mockSiteConfig('/myorg-f/mysite', { data: [{ key: 'editor.path', value: '/myorg-f/mysite=https://da.live/form#' }] }); + await daBrowseComp.getEditor(true); + expect(daBrowseComp.hidePublishConfs).to.deep.equal([]); + }); }); describe('isRootFolder', () => { diff --git a/test/unit/blocks/edit/da-title/da-title.test.js b/test/unit/blocks/edit/da-title/da-title.test.js index a3f1ec75d..a4f7962f8 100644 --- a/test/unit/blocks/edit/da-title/da-title.test.js +++ b/test/unit/blocks/edit/da-title/da-title.test.js @@ -271,6 +271,37 @@ describe('DaTitle', () => { expect(el._actions.available).to.include('publish'); window.fetch = origFetch; }); + + it('removes publish when hidePublish lives in a non-first multi-sheet tab named "data"', async () => { + const configResp = { + permissions: { data: [{ path: '/', groups: 'everyone', actions: 'write' }] }, + data: { data: [{ key: 'editor.hidePublish', value: '/filterorg3/filtersite3/test' }] }, + ':names': ['permissions', 'data'], + ':type': 'multi-sheet', + }; + const origFetch = window.fetch; + window.fetch = async (url, opts) => { + if (url.includes('/config/filterorg3')) { + return new Response(JSON.stringify(configResp), { status: 200 }); + } + return origFetch(url, opts); + }; + + el = await fixture({ + details: createDetails({ + org: 'filterorg3', + site: 'filtersite3', + path: '/test/page', + fullpath: '/filterorg3/filtersite3/test/page', + }), + }); + el._actions = { available: ['preview', 'publish'] }; + await el.filterActions(); + + expect(el._actions.available).to.include('preview'); + expect(el._actions.available).to.not.include('publish'); + window.fetch = origFetch; + }); }); describe('collab status = "unsaved"', () => { From b65d2e9a445bbcd1a1926221750af2a8f90a7ce8 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Thu, 13 Aug 2026 10:45:36 -0700 Subject: [PATCH 2/6] test(browse): cover hidePublishConfs forwarding from da-list to da-actionbar Co-Authored-By: Claude --- test/unit/blocks/browse/da-list/da-list-render.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/unit/blocks/browse/da-list/da-list-render.test.js b/test/unit/blocks/browse/da-list/da-list-render.test.js index 9f25fdbc3..307e52d16 100644 --- a/test/unit/blocks/browse/da-list/da-list-render.test.js +++ b/test/unit/blocks/browse/da-list/da-list-render.test.js @@ -182,6 +182,13 @@ describe('da-list render', () => { const bar = el.shadowRoot.querySelector('da-actionbar'); expect(bar.getAttribute('data-visible')).to.equal('true'); }); + + it('Forwards hidePublishConfs to the action bar', async () => { + await fixture({ fullpath: '/o/r', hidePublishConfs: ['/o/r/blog'] }); + await rerender(); + const bar = el.shadowRoot.querySelector('da-actionbar'); + expect(bar.hidePublishConfs).to.deep.equal(['/o/r/blog']); + }); }); describe('da-list pagination observer', () => { From 2188582f43adab1c6884dad269beb536798f3d8c Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Thu, 13 Aug 2026 11:39:17 -0700 Subject: [PATCH 3/6] style: fix lint (object-curly-newline) in da-browse.js Co-Authored-By: Claude Sonnet 5 --- blocks/browse/da-browse/da-browse.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/blocks/browse/da-browse/da-browse.js b/blocks/browse/da-browse/da-browse.js index 3f8d54926..3f5fc137e 100644 --- a/blocks/browse/da-browse/da-browse.js +++ b/blocks/browse/da-browse/da-browse.js @@ -1,7 +1,5 @@ import { LitElement, html, nothing } from 'da-lit'; -import { - getSheetByName, getFirstSheet, fetchDaConfigs, -} from '../../shared/utils.js'; +import { getSheetByName, getFirstSheet, fetchDaConfigs } from '../../shared/utils.js'; import { getNx, sanitizePathParts, getNxEWFlags } from '../../../scripts/utils.js'; import { getChatPanelContent } from '../../shared/chat-panel.js'; From 16b576f24312716714839b3d32e1a9447ebe6877 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Fri, 21 Aug 2026 09:55:19 -0700 Subject: [PATCH 4/6] fix(test): stub the isHlx6 ping check in da-browse hidePublish tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mockSiteConfig returned a headers-less plain object for every request, including the HLX_ADMIN ping isHlx6() makes before the config fetch; its headers.get() call then threw. mockConfig already special-cased this ping URL — apply the same fix to mockSiteConfig. Co-Authored-By: Claude Sonnet 5 --- test/unit/blocks/browse/da-browse/da-browse.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/blocks/browse/da-browse/da-browse.test.js b/test/unit/blocks/browse/da-browse/da-browse.test.js index 131698b7b..1a84c2c6b 100644 --- a/test/unit/blocks/browse/da-browse/da-browse.test.js +++ b/test/unit/blocks/browse/da-browse/da-browse.test.js @@ -406,6 +406,9 @@ describe('DaBrowse Component', () => { // catch-all mock would return the same rows for both, doubling entries. function mockSiteConfig(fullpath, json) { window.fetch = async (url) => { + // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that + // with a real Response (so its headers.get() call is safe) and defer everything else. + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); if (url.includes(`/config${fullpath}/`)) return { ok: true, json: async () => json }; return { ok: true, json: async () => ({ data: [] }) }; }; From 11ccf10d6bf5005487c817304d8bc33aebd0fbfd Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 1 Sep 2026 12:33:23 -0700 Subject: [PATCH 5/6] revert(browse,edit): drop "data" sheet-by-name lookup for editor.hidePublish Reverts the getSheetByName fallback in da-browse.js/da-title.js back to plain getFirstSheet; better multi-sheet config lookup will be tackled separately. The editor.hidePublish feature itself is unchanged. Co-Authored-By: Claude Sonnet 5 --- blocks/browse/da-browse/da-browse.js | 5 ++- blocks/edit/da-title/da-title.js | 5 ++- .../blocks/browse/da-browse/da-browse.test.js | 12 ------- .../blocks/edit/da-title/da-title.test.js | 31 ------------------- 4 files changed, 4 insertions(+), 49 deletions(-) diff --git a/blocks/browse/da-browse/da-browse.js b/blocks/browse/da-browse/da-browse.js index dcadafbf4..39aca9e87 100644 --- a/blocks/browse/da-browse/da-browse.js +++ b/blocks/browse/da-browse/da-browse.js @@ -1,5 +1,5 @@ import { LitElement, html, nothing } from 'da-lit'; -import { getSheetByName, getFirstSheet, fetchDaConfigs } from '../../shared/utils.js'; +import { getFirstSheet, fetchDaConfigs } from '../../shared/utils.js'; import { getNx, sanitizePathParts, getNxEWFlags } from '../../../scripts/utils.js'; import { getChatPanelContent } from '../../shared/chat-panel.js'; @@ -161,8 +161,7 @@ export default class DaBrowse extends LitElement { if (reFetch) { const { org, site } = this.details; const configs = await Promise.all(fetchDaConfigs({ org, site })); - const rows = configs.filter(Boolean).reverse() - .flatMap((c) => getSheetByName(c, 'data') ?? getFirstSheet(c) ?? []); + const rows = configs.filter(Boolean).reverse().flatMap((c) => getFirstSheet(c) || []); this.editorConfs = rows.reduce((acc, row) => { if (row.key === 'editor.path') acc.push(row.value); return acc; diff --git a/blocks/edit/da-title/da-title.js b/blocks/edit/da-title/da-title.js index 7b5bf29b3..e141913e5 100644 --- a/blocks/edit/da-title/da-title.js +++ b/blocks/edit/da-title/da-title.js @@ -5,7 +5,7 @@ import { saveDaConfig, getAemHrefs, } from '../utils/helpers.js'; -import { delay, fetchDaConfigs, getSheetByName, getFirstSheet, aemAction } from '../../shared/utils.js'; +import { delay, fetchDaConfigs, getFirstSheet, aemAction } from '../../shared/utils.js'; import { createVersion } from '../../shared/version/version-actions.js'; import inlinesvg from '../../shared/inlinesvg.js'; import getSheet from '../../shared/sheet.js'; @@ -114,8 +114,7 @@ export default class DaTitle extends LitElement { async filterActions() { const { org, site, fullpath } = this.details; const configs = await Promise.all(fetchDaConfigs({ org, site })); - const configTab = configs.filter(Boolean) - .flatMap((config) => getSheetByName(config, 'data') ?? getFirstSheet(config) ?? []); + const configTab = configs.flatMap((config) => getFirstSheet(config) || []); // Check which actions should be allowed for the document based on config const publishConfigs = configTab.filter((c) => c.key === 'editor.hidePublish'); diff --git a/test/unit/blocks/browse/da-browse/da-browse.test.js b/test/unit/blocks/browse/da-browse/da-browse.test.js index 1a84c2c6b..b05bd11b9 100644 --- a/test/unit/blocks/browse/da-browse/da-browse.test.js +++ b/test/unit/blocks/browse/da-browse/da-browse.test.js @@ -426,18 +426,6 @@ describe('DaBrowse Component', () => { expect(daBrowseComp.hidePublishConfs).to.deep.equal(['/myorg-d/mysite/blog']); }); - it('collects editor.hidePublish rows living in a non-first multi-sheet tab named "data"', async () => { - daBrowseComp.details = { fullpath: '/myorg-e/mysite/folder', org: 'myorg-e', site: 'mysite', owner: 'myorg-e', depth: 3 }; - mockSiteConfig('/myorg-e/mysite', { - permissions: { data: [{ path: '/', groups: 'everyone', actions: 'write' }] }, - data: { data: [{ key: 'editor.hidePublish', value: '/myorg-e/mysite/blog' }] }, - ':names': ['permissions', 'data'], - ':type': 'multi-sheet', - }); - await daBrowseComp.getEditor(true); - expect(daBrowseComp.hidePublishConfs).to.deep.equal(['/myorg-e/mysite/blog']); - }); - it('returns an empty hidePublishConfs when no editor.hidePublish rows exist', async () => { daBrowseComp.details = { fullpath: '/myorg-f/mysite/folder', org: 'myorg-f', site: 'mysite', owner: 'myorg-f', depth: 3 }; mockSiteConfig('/myorg-f/mysite', { data: [{ key: 'editor.path', value: '/myorg-f/mysite=https://da.live/form#' }] }); diff --git a/test/unit/blocks/edit/da-title/da-title.test.js b/test/unit/blocks/edit/da-title/da-title.test.js index a4f7962f8..a3f1ec75d 100644 --- a/test/unit/blocks/edit/da-title/da-title.test.js +++ b/test/unit/blocks/edit/da-title/da-title.test.js @@ -271,37 +271,6 @@ describe('DaTitle', () => { expect(el._actions.available).to.include('publish'); window.fetch = origFetch; }); - - it('removes publish when hidePublish lives in a non-first multi-sheet tab named "data"', async () => { - const configResp = { - permissions: { data: [{ path: '/', groups: 'everyone', actions: 'write' }] }, - data: { data: [{ key: 'editor.hidePublish', value: '/filterorg3/filtersite3/test' }] }, - ':names': ['permissions', 'data'], - ':type': 'multi-sheet', - }; - const origFetch = window.fetch; - window.fetch = async (url, opts) => { - if (url.includes('/config/filterorg3')) { - return new Response(JSON.stringify(configResp), { status: 200 }); - } - return origFetch(url, opts); - }; - - el = await fixture({ - details: createDetails({ - org: 'filterorg3', - site: 'filtersite3', - path: '/test/page', - fullpath: '/filterorg3/filtersite3/test/page', - }), - }); - el._actions = { available: ['preview', 'publish'] }; - await el.filterActions(); - - expect(el._actions.available).to.include('preview'); - expect(el._actions.available).to.not.include('publish'); - window.fetch = origFetch; - }); }); describe('collab status = "unsaved"', () => { From 9321faa81a81ecbdf8d4c95a9b73dedce9d1f857 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 1 Sep 2026 13:02:39 -0700 Subject: [PATCH 6/6] fix(test): stub WebSocket in prose/index createConnection tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createConnection opened a real y-websocket connection to admin.da.live, letting the real socket's own close race the test's synthetic connection-close emit and double-count refreshToken calls — flaky on CI. Stub WebSocket like the rapid-reconnect describe block already does. Co-Authored-By: Claude Sonnet 5 --- test/unit/blocks/edit/prose/index.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index ab31501e3..0add80df4 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -31,6 +31,21 @@ const stubHlx6Ping = () => { return () => { window.fetch = saved; }; }; +// createConnection opens a real y-websocket connection to admin.da.live. Left +// unstubbed, the real socket's own close (e.g. a genuine 401 from the actual +// server) can race the test's synthetic 'connection-close' emit and double-count +// handler side effects (e.g. refreshToken calls). Stub WebSocket so no real +// socket is ever opened. Returns a restore fn. +const stubWebSocket = () => { + const saved = window.WebSocket; + window.WebSocket = function FakeWebSocket() { + this.readyState = 0; + this.close = () => {}; + this.send = () => {}; + }; + return () => { window.WebSocket = saved; }; +}; + function buildFakeWsProvider({ withSynced = false } = {}) { const listeners = new Map(); const winListeners = []; @@ -81,12 +96,15 @@ function buildFakeWsProvider({ withSynced = false } = {}) { describe('prose/index createConnection', () => { let restoreFetch; + let restoreWebSocket; beforeEach(() => { restoreFetch = stubHlx6Ping(); + restoreWebSocket = stubWebSocket(); window.localStorage.removeItem('nx-ims'); }); afterEach(() => { restoreFetch(); + restoreWebSocket(); // Always remove rather than restoring a prior value — if a leak entered // this block, restoring it would propagate the leak to later test files. window.localStorage.removeItem('nx-ims');