'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const test = require('node:test'); const root = path.resolve(__dirname, '..', '..'); const { MiniDocument } = require('../helpers/mini-dom.cjs'); function tabMarkup(document, { firstPage = 1, firstSegmentLoaded = true } = {}) { const root = document.createElement('div'); root.className = 'widget-tab-post style-mini posts-mini-lists jio-widget-box'; root.setAttribute('data-widget', 'tab_posts'); root.setAttribute('data-instance', 'widget-7'); const nav = document.createElement('ul'); nav.className = 'list-inline scroll-x no-scrollbar'; nav.setAttribute('role', 'tablist'); nav.clientWidth = 260; nav.scrollWidth = 420; const tabOne = addTab(document, nav, 0, 'tab-one', 'panel-one', true); const tabTwo = addTab(document, nav, 1, 'tab-two', 'panel-two', false); nav.rectLeft = 20; tabOne.rectLeft = 100; tabOne.rectWidth = 100; tabTwo.rectLeft = 380; tabTwo.rectWidth = 100; root.appendChild(nav); const content = document.createElement('div'); content.className = 'tab-content'; content.appendChild(addPane(document, 'panel-one', 'tab-one', 0, firstPage, firstSegmentLoaded, true)); content.appendChild(addPane(document, 'panel-two', 'tab-two', 1, 1, false, false)); root.appendChild(content); return { root, nav, tabOne, tabTwo }; } function addTab(document, nav, segment, id, panelId, selected) { const item = document.createElement('li'); if (selected) item.classList.add('active'); const tab = document.createElement('a'); tab.setAttribute('role', 'tab'); tab.setAttribute('id', id); tab.setAttribute('aria-controls', panelId); tab.setAttribute('aria-selected', selected ? 'true' : 'false'); tab.setAttribute('tabindex', selected ? '0' : '-1'); tab.setAttribute('data-segment', String(segment)); item.appendChild(tab); nav.appendChild(item); return tab; } function addPane(document, id, tabId, segment, page, loaded, active) { const pane = document.createElement('div'); pane.className = 'tab-pane fade' + (active ? ' active in' : ''); pane.setAttribute('id', id); pane.setAttribute('role', 'tabpanel'); pane.setAttribute('aria-labelledby', tabId); const pager = document.createElement('div'); pager.className = 'widget-ajaxpager'; pager.setAttribute('data-widget-items', ''); pager.setAttribute('data-widget', 'tab_posts'); pager.setAttribute('data-instance', 'widget-7'); pager.setAttribute('data-segment', String(segment)); pager.setAttribute('data-page', String(page)); if (loaded) pager.setAttribute('data-loaded', '1'); if (active && loaded) { pager.innerHTML = '

Page one

'; pager.appendChild(nextPage(document, 2)); } else { const loader = document.createElement('div'); loader.className = 'post_ajax_loader'; pager.appendChild(loader); } pane.appendChild(pager); return pane; } function nextPage(document, page) { const pagination = document.createElement('div'); pagination.className = 'ajax-pag'; const link = document.createElement('a'); link.setAttribute('data-next-page', String(page)); pagination.appendChild(link); return pagination; } function addJumpControl(document, pager, { max = 20, value = '4' } = {}) { const pagination = document.createElement('div'); pagination.className = 'ajax-pag'; const wrapper = document.createElement('span'); wrapper.className = 'pag-jump'; const input = document.createElement('input'); input.setAttribute('data-widget-jump', ''); input.setAttribute('type', 'text'); input.max = max; input.value = value; const button = document.createElement('button'); button.setAttribute('data-widget-jump-go', ''); wrapper.appendChild(input); wrapper.appendChild(button); pagination.appendChild(wrapper); pager.appendChild(pagination); return { input, button }; } function postsMarkup(document, unveiled = false) { const root = document.createElement('div'); root.className = 'widget-main-post style-list'; const pager = document.createElement('div'); pager.className = 'widget-ajaxpager ' + (unveiled ? 'lazyloaded' : 'lazyload'); pager.setAttribute('data-widget-items', ''); pager.setAttribute('data-widget-lazy', ''); pager.setAttribute('data-widget', 'posts'); pager.setAttribute('data-instance', 'jio_widget_posts-2'); pager.setAttribute('data-page', '1'); pager.setAttribute('aria-busy', 'true'); pager.innerHTML = '
Loading
'; root.appendChild(pager); return { root }; } function createRuntime({ fetch, pages, posts = false, unveiled = false, restUrl = '/wp-json/jio/v1/widgets/', observe = false } = {}) { const document = new MiniDocument(); const { root: widget, nav, tabOne, tabTwo } = posts ? postsMarkup(document, unveiled) : tabMarkup(document); document.body.appendChild(widget); const autoFun = []; function jQuery() { return { on(event, callback) { if (event === 'auto_fun') autoFun.push(callback); return this; }, trigger(event) { if (event === 'auto_fun') autoFun.forEach((callback) => callback()); return this; }, }; } const requests = []; const config = { restUrl, i18n: { error: 'error', retry: 'retry' } }; const fetchStub = (url) => { requests.push(url); if (fetch) return fetch(url); const page = Number(new URL(url, 'https://example.test').searchParams.get('page')); const segment = new URL(url, 'https://example.test').searchParams.get('segment'); const response = (pages && pages[`${segment}:${page}`]) || { html: `

Returned ${segment}/${page}

`, page, paginate_html: page < 3 ? `
more
` : '', }; return Promise.resolve({ ok: true, json: () => Promise.resolve(response) }); }; let notifyMutation; const Observer = observe ? class { constructor(callback) { notifyMutation = callback; } observe() {} } : undefined; const window = { jioWidgets: config, location: { href: 'https://example.test/' }, matchMedia: () => ({ matches: false }), jQuery, }; if (observe) window.MutationObserver = Observer; vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/widgets/frontend.js'), 'utf8'), { window, document, fetch: fetchStub, Promise, Array, Number, String, Object, Math, encodeURIComponent, URL, MutationObserver: Observer, }); return { document, widget, nav, tabOne, tabTwo, requests, autoFun, notifyMutation }; } const tick = () => new Promise((resolve) => setImmediate(resolve)); const pageRequests = (requests) => requests.map((url) => Number(new URL(url, 'https://example.test').searchParams.get('page'))); function pendingFetch() { const pending = []; return { pending, fetch(url) { return new Promise((resolve, reject) => pending.push({ url, resolve(data) { resolve({ ok: true, json: () => Promise.resolve(data) }); }, reject, })); }, }; } function addPageLink(document, pager, page) { const link = document.createElement('a'); link.className = 'page-numbers'; link.setAttribute('data-widget-page', String(page)); pager.querySelector('.ajax-pag').appendChild(link); return link; } function assertOnlyLoader(pager, loader, message) { assert.equal(pager.children.length, 1, message); assert.equal(pager.firstChild, loader); } test('numbered pagination replaces the old page with its skeleton before the response arrives', async () => { const transport = pendingFetch(); const runtime = createRuntime({ fetch: transport.fetch }); const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); const originalPost = pager.querySelector('[data-post-id="post-one"]'); const loader = runtime.document.createElement('div'); loader.className = 'post_ajax_loader'; pager.appendChild(loader); addPageLink(runtime.document, pager, 2).click(); assert.equal(pager.querySelectorAll('[data-post-id]').length, 0, 'old rows must stop occupying space while the replacement is pending'); assert.equal(originalPost.parentNode, null); assert.equal(pager.querySelector('.ajax-pag'), null, 'old page controls leave the loading page'); assertOnlyLoader(pager, loader); assert.equal(loader.classList.contains('is-loading'), true); assert.equal(pager.getAttribute('aria-busy'), 'true'); assert.equal(pager.dataset.page, '1', 'the current page changes only after a successful response'); transport.pending[0].resolve({ html: 'Page two', page: 2, paginate_html: '
3
', }); await tick(); assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-two']); assert.equal(pager.querySelector('.post_ajax_loader'), loader); assert.equal(loader.classList.contains('is-loading'), false); assert.equal(pager.getAttribute('aria-busy'), 'false'); assert.equal(pager.dataset.page, '2'); pager.querySelector('[data-widget-page="3"]').click(); assertOnlyLoader(pager, loader, 'the next replacement reuses the same skeleton in the cleared page'); assert.equal(loader.classList.contains('is-loading'), true); transport.pending[1].resolve({ html: '', page: 3, paginate_html: '' }); await tick(); assert.equal(pager.dataset.page, '3'); assert.equal(loader.classList.contains('is-loading'), false); }); test('a failed numbered page restores the old content and retries the requested replacement', async () => { const transport = pendingFetch(); const runtime = createRuntime({ fetch: transport.fetch }); const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); const originalPost = pager.querySelector('[data-post-id="post-one"]'); const originalPagination = pager.querySelector('.ajax-pag'); const link = addPageLink(runtime.document, pager, 2); link.click(); const loader = pager.querySelector('.post_ajax_loader'); link.click(); assert.deepEqual(pageRequests(runtime.requests), [2], 'a detached trigger cannot start a duplicate request'); transport.pending[0].reject(new Error('temporary failure')); await tick(); assert.equal(pager.querySelector('[data-post-id="post-one"]'), originalPost, 'failure restores the original row node'); assert.equal(pager.querySelector('.ajax-pag'), originalPagination, 'failure restores the original pagination controls'); assert.equal(pager.dataset.page, '1'); assert.equal(pager.getAttribute('aria-busy'), 'false'); assert.equal(loader.classList.contains('is-loading'), false); const retry = pager.querySelector('[data-widget-retry]'); assert.equal(retry.dataset.retryPage, '2'); assert.equal(retry.dataset.retryAppend, '0'); retry.click(); assert.deepEqual(pageRequests(runtime.requests), [2, 2]); assertOnlyLoader(pager, loader, 'retry clears the restored page before showing its replacement skeleton'); transport.pending[1].resolve({ html: 'Page two', page: 2, paginate_html: '', }); await tick(); assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-two']); assert.equal(pager.querySelector('[data-widget-retry]'), null); assert.equal(pager.dataset.page, '2'); }); test('load-more keeps existing rows and places its pending skeleton after them', async () => { const transport = pendingFetch(); const runtime = createRuntime({ fetch: transport.fetch }); const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); const originalPost = pager.querySelector('[data-post-id="post-one"]'); pager.querySelector('[data-next-page="2"]').click(); const loader = pager.querySelector('.post_ajax_loader'); assert.equal(pager.querySelector('[data-post-id="post-one"]'), originalPost); assert.equal(pager.children.indexOf(loader), pager.children.indexOf(originalPost) + 1); assert.equal(loader.classList.contains('is-loading'), true); transport.pending[0].resolve({ html: 'Page two', page: 2, paginate_html: '', }); await tick(); assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-one', 'post-two']); assert.equal(pager.querySelector('.post_ajax_loader'), loader); assert.equal(pager.children.indexOf(loader), 2); assert.equal(loader.classList.contains('is-loading'), false); }); test('jump pagination clears the page during loading and keeps the skeleton scoped to that tab', async () => { const transport = pendingFetch(); const runtime = createRuntime({ fetch: transport.fetch }); const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); const otherPager = runtime.widget.querySelector('#panel-two [data-widget-items]'); const otherChildren = otherPager.children.slice(); const { button } = addJumpControl(runtime.document, pager, { value: '4' }); button.click(); const loader = pager.querySelector('.post_ajax_loader'); assertOnlyLoader(pager, loader); assert.equal(loader.classList.contains('is-loading'), true); assert.equal(otherPager.children.length, otherChildren.length); otherChildren.forEach((child, index) => assert.equal(otherPager.children[index], child, 'another tab keeps its existing DOM')); assert.equal(otherPager.querySelector('.is-loading'), null); transport.pending[0].resolve({ html: 'Page four', page: 4, paginate_html: '' }); await tick(); assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-four']); assert.equal(pager.dataset.page, '4'); }); test('tab panes cache results and keep their current page after switching away and back', async () => { const runtime = createRuntime(); const paneOne = runtime.widget.querySelector('#panel-one'); const paneTwo = runtime.widget.querySelector('#panel-two'); const pagerOne = paneOne.querySelector('[data-widget-items]'); const pagerTwo = paneTwo.querySelector('[data-widget-items]'); runtime.tabTwo.click(); await tick(); assert.deepEqual(pageRequests(runtime.requests), [1]); const pageTwoMore = pagerTwo.querySelector('[data-next-page="2"]'); assert.ok(pageTwoMore, 'the returned REST markup is parsed and inserted into the selected pane'); pageTwoMore.click(); await tick(); assert.deepEqual(pageRequests(runtime.requests), [1, 2]); assert.equal(pagerTwo.dataset.page, '2'); runtime.tabOne.click(); runtime.tabTwo.click(); await tick(); assert.deepEqual(pageRequests(runtime.requests), [1, 2], 'a cached pane does not issue another first-page request'); assert.equal(pagerOne.dataset.page, '1', 'switching tabs preserves the initial pane page'); assert.equal(pagerTwo.dataset.page, '2', 'switching back preserves the loaded pane page'); assert.equal(pagerTwo.querySelectorAll('[data-post-id]').length, 2, 'returned page markup remains in the cached pane'); }); test('arrow keys move tab focus, activate the adjacent pane, and center an overflowing nav item', async () => { const runtime = createRuntime(); const keyEvent = runtime.tabOne.dispatch('keydown', { key: 'ArrowRight' }); await tick(); assert.equal(keyEvent.defaultPrevented, true); assert.equal(runtime.document.activeElement, runtime.tabTwo); assert.equal(runtime.tabTwo.getAttribute('aria-selected'), 'true'); assert.equal(runtime.widget.querySelector('#panel-two').classList.contains('active'), true); assert.equal(runtime.nav.scrollCalls.length, 1); assert.equal(runtime.nav.scrollCalls[0].left, 280); assert.equal(runtime.nav.scrollCalls[0].behavior, 'smooth'); assert.deepEqual(pageRequests(runtime.requests), [1]); }); test('overflowing tab centering includes the nav current scroll offset', () => { const runtime = createRuntime(); runtime.nav.scrollLeft = 35; runtime.tabTwo.click(); assert.equal(runtime.nav.scrollCalls.length, 1); assert.equal(runtime.nav.scrollCalls[0].left, 315, 'the target uses viewport bounds plus current nav scrollLeft'); }); test('a failed load-more can retry page two and append it without replacing page one', async () => { let pageTwoAttempts = 0; const runtime = createRuntime({ fetch: (url) => { const page = Number(new URL(url, 'https://example.test').searchParams.get('page')); if (page === 2 && pageTwoAttempts++ === 0) return Promise.reject(new Error('temporary failure')); return Promise.resolve({ ok: true, json: () => Promise.resolve({ html: '

Page two

', page, paginate_html: '', }), }); } }); const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); pager.querySelector('[data-next-page="2"]').click(); await tick(); const retry = pager.querySelector('[data-widget-retry]'); assert.ok(retry, 'a failed append exposes the runtime retry control'); assert.equal(retry.dataset.retryPage, '2'); assert.equal(retry.dataset.retryAppend, '1'); retry.click(); await tick(); assert.deepEqual(pageRequests(runtime.requests), [2, 2], 'retry repeats the failed page rather than resetting to page one'); assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-one', 'post-two']); assert.equal(pager.dataset.page, '2'); assert.equal(runtime.autoFun.length, 1, 'successful markup insertion initializes widgets through auto_fun'); }); test('jump button replaces the selected pane, keeps its loader, and stops the legacy click handler', async () => { const runtime = createRuntime(); const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); const loader = runtime.document.createElement('div'); loader.className = 'post_ajax_loader'; pager.insertBefore(loader, pager.querySelector('.ajax-pag')); const { button } = addJumpControl(runtime.document, pager, { value: '4' }); let legacyClicks = 0; runtime.document.body.addEventListener('click', () => { legacyClicks += 1; }); const click = button.dispatch('click'); await tick(); assert.equal(click.defaultPrevented, true); assert.equal(click.propagationStopped, true); assert.equal(legacyClicks, 0, 'the legacy pag_jump_go document handler cannot process the same click'); assert.deepEqual(pageRequests(runtime.requests), [4]); assert.equal(pager.dataset.page, '4'); assert.equal(pager.querySelector('[data-post-id="post-0-4"]') !== null, true, 'the returned page markup replaces the active pane items'); assert.equal(pager.querySelector('[data-post-id="post-one"]'), null); assert.equal(pager.querySelector('.post_ajax_loader'), loader, 'replacement preserves the loader node for subsequent requests'); }); test('Enter submits a valid jump and stops the legacy key handler', async () => { const runtime = createRuntime(); const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); const loader = runtime.document.createElement('div'); loader.className = 'post_ajax_loader'; pager.insertBefore(loader, pager.querySelector('.ajax-pag')); const { input } = addJumpControl(runtime.document, pager, { value: '5' }); let legacyKeydowns = 0; runtime.document.body.addEventListener('keydown', () => { legacyKeydowns += 1; }); const keydown = input.dispatch('keydown', { key: 'Enter' }); await tick(); assert.equal(keydown.defaultPrevented, true); assert.equal(keydown.propagationStopped, true); assert.equal(legacyKeydowns, 0, 'the legacy pag_jump_go key handler cannot submit twice'); assert.deepEqual(pageRequests(runtime.requests), [5]); assert.equal(pager.dataset.page, '5'); assert.ok(pager.querySelector('[data-post-id="post-0-5"]')); assert.equal(pager.querySelector('.post_ajax_loader'), loader); }); test('jump input clamps pages to max and rejects invalid or current pages without fetching', async () => { const runtime = createRuntime(); const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); const { input } = addJumpControl(runtime.document, pager, { max: 8, value: '99' }); const inputEvent = input.dispatch('input'); assert.equal(input.value, '8'); assert.equal(inputEvent.propagationStopped, true); input.value = 'not-a-page'; input.dispatch('input'); assert.equal(input.value, ''); input.dispatch('keydown', { key: 'Enter' }); assert.equal(runtime.requests.length, 0, 'invalid input does not request a page'); input.value = '1'; const currentPage = input.dispatch('keydown', { key: 'Enter' }); await tick(); assert.equal(currentPage.defaultPrevented, true); assert.equal(runtime.requests.length, 0, 'submitting the already selected page does not request again'); assert.equal(runtime.document.activeElement, input, 'invalid and current page values return focus to the input'); }); test('article widget waits for its own lazy reveal and fetches the first page once', async () => { const transport = pendingFetch(); const runtime = createRuntime({ posts: true, fetch: transport.fetch }); const pager = runtime.widget.querySelector('[data-widget-items]'); assert.equal(runtime.requests.length, 0, 'offscreen widgets keep their placeholder without fetching'); assert.ok(pager.querySelector('.post_ajax_loader.is-loading')); pager.querySelector('.placeholder').dispatch('lazybeforeunveil'); assert.equal(runtime.requests.length, 0, 'a descendant image reveal does not load the pager'); pager.dispatch('lazybeforeunveil'); pager.dispatch('lazybeforeunveil'); assert.deepEqual(pageRequests(runtime.requests), [1]); const url = new URL(runtime.requests[0]); assert.equal(url.pathname, '/wp-json/jio/v1/widgets/posts/items'); assert.equal(url.searchParams.get('instance'), 'jio_widget_posts-2'); transport.pending[0].resolve({ html: 'First article', page: 1, paginate_html: '' }); await tick(); assert.ok(pager.querySelector('[data-post-id="first"]')); assert.equal(pager.dataset.loaded, '1'); assert.equal(pager.getAttribute('aria-busy'), 'false'); assert.equal(pager.querySelector('.post_ajax_loader').classList.contains('is-loading'), false); pager.dispatch('lazybeforeunveil'); assert.equal(runtime.requests.length, 1, 'a completed first page is never fetched again by lazySizes'); }); test('article widget loads when lazySizes unveiled it before the widget client initialized', async () => { const runtime = createRuntime({ posts: true, unveiled: true }); assert.deepEqual(pageRequests(runtime.requests), [1]); await tick(); const pager = runtime.widget.querySelector('[data-widget-items]'); assert.equal(pager.dataset.loaded, '1'); assert.ok(pager.querySelector('[data-post-id]')); }); test('article widget failure exposes a retry that replaces the initial skeleton', async () => { const transport = pendingFetch(); const runtime = createRuntime({ posts: true, fetch: transport.fetch }); const pager = runtime.widget.querySelector('[data-widget-items]'); pager.dispatch('lazybeforeunveil'); transport.pending[0].reject(new Error('offline')); await tick(); assert.equal(pager.getAttribute('aria-busy'), 'false'); assert.equal(pager.querySelector('.post_ajax_loader').classList.contains('is-loading'), false); pager.querySelector('[data-widget-retry]').click(); assert.deepEqual(pageRequests(runtime.requests), [1, 1]); transport.pending[1].resolve({ html: 'Retried', page: 1, paginate_html: '' }); await tick(); assert.ok(pager.querySelector('[data-post-id="retried"]')); assert.equal(pager.querySelector('[data-widget-retry]'), null); }); test('article REST addresses preserve the route for plain WordPress permalinks', async () => { const runtime = createRuntime({ posts: true, unveiled: true, restUrl: 'https://example.test/blog/?rest_route=/jio/v1/widgets/' }); const url = new URL(runtime.requests[0]); assert.equal(url.pathname, '/blog/'); assert.equal(url.searchParams.get('rest_route'), '/jio/v1/widgets/posts/items'); assert.equal(url.searchParams.get('instance'), 'jio_widget_posts-2'); assert.equal(url.searchParams.get('page'), '1'); await tick(); assert.equal(runtime.widget.querySelector('[data-widget-items]').dataset.loaded, '1'); }); test('a pager inserted as the mutation root still receives its lazy listener', async () => { const runtime = createRuntime({ observe: true }); const pager = postsMarkup(runtime.document).root.querySelector('[data-widget-items]'); pager.nodeType = 1; runtime.document.body.appendChild(pager); runtime.notifyMutation([{ addedNodes: [pager] }]); pager.dispatch('lazybeforeunveil'); await tick(); assert.deepEqual(pageRequests(runtime.requests), [1]); assert.equal(pager.dataset.loaded, '1'); });