Files
wordpress-theme-aksrt/tests/widget-system/tab-posts-runtime.test.cjs
T

550 lines
25 KiB
JavaScript

'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 zib-widget';
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 = '<posts class="posts-mini ajax-item" data-post-id="post-one"><div class="posts-mini-con"><h2 class="item-heading"><a>Page one</a></h2></div></posts>';
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', 'aksrt_widget_posts-2');
pager.setAttribute('data-page', '1');
pager.setAttribute('aria-busy', 'true');
pager.innerHTML = '<div class="post_ajax_loader is-loading"><div class="posts-item placeholder">Loading</div></div>';
root.appendChild(pager);
return { root };
}
function createRuntime({ fetch, pages, posts = false, unveiled = false, restUrl = '/wp-json/aksrt/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: `<posts class="posts-mini ajax-item" data-post-id="post-${segment}-${page}"><div class="posts-mini-con"><h2 class="item-heading"><a>Returned ${segment}/${page}</a></h2></div></posts>`,
page,
paginate_html: page < 3 ? `<div class="ajax-pag"><a data-next-page="${page + 1}">more</a></div>` : '',
};
return Promise.resolve({ ok: true, json: () => Promise.resolve(response) });
};
let notifyMutation;
const Observer = observe ? class {
constructor(callback) { notifyMutation = callback; }
observe() {}
} : undefined;
const window = {
aksrtWidgets: 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: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
page: 2,
paginate_html: '<div class="ajax-pag"><a class="page-numbers" data-widget-page="3">3</a></div>',
});
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: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
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: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
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: '<posts class="posts-mini ajax-item" data-post-id="post-four">Page four</posts>', 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: '<posts class="posts-mini ajax-item" data-post-id="post-two"><h2 class="item-heading"><a>Page two</a></h2></posts>',
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/aksrt/v1/widgets/posts/items');
assert.equal(url.searchParams.get('instance'), 'aksrt_widget_posts-2');
transport.pending[0].resolve({ html: '<posts class="posts-item list ajax-item" data-post-id="first">First article</posts>', 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: '<posts class="posts-item card ajax-item" data-post-id="retried">Retried</posts>', 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=/aksrt/v1/widgets/' });
const url = new URL(runtime.requests[0]);
assert.equal(url.pathname, '/blog/');
assert.equal(url.searchParams.get('rest_route'), '/aksrt/v1/widgets/posts/items');
assert.equal(url.searchParams.get('instance'), 'aksrt_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');
});