Skip to content
71 changes: 71 additions & 0 deletions cypress/e2e/perf-resize-throttle.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** cSpell:ignore vscomp */

// Tests for P1: the global window 'resize' handler used to run per-instance height
// recomputation on every resize tick, unthrottled, and threw if a wrapper had no instance.

describe('Performance: window resize handler', () => {
const mountId = 'vs-p1-resize';

const mount = (win: Window) => {
const doc = win.document;
const existing = doc.getElementById(mountId);
if (existing) {
existing.remove();
}
const $ele = doc.createElement('div');
$ele.id = mountId;
doc.body.appendChild($ele);
// @ts-expect-error - VirtualSelect attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }] });
return $ele;
};

it('throttles resize so onResize runs far fewer times than resize events fire', () => {
cy.visit('get-started');
cy.window().then((win) => {
mount(win);
// @ts-expect-error - instance back-reference
const instance = win.document.getElementById(mountId).virtualSelect;
cy.spy(instance, 'onResize').as('onResize');

for (let i = 0; i < 10; i += 1) {
win.dispatchEvent(new win.Event('resize'));
}
});

// allow the trailing-edge call to fire
cy.wait(250);
cy.get('@onResize').its('callCount').should('be.greaterThan', 0).and('be.lessThan', 10);
});

it('still updates the dropdown on resize (no regression)', () => {
cy.visit('get-started');
cy.window().then((win) => mount(win));

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`).find('.vscomp-option').should('have.length.greaterThan', 0);

cy.window().then((win) => win.dispatchEvent(new win.Event('resize')));
cy.wait(150);

// dropdown remains open and functional after a resize
cy.get(`#${mountId}`).find('.vscomp-ele-wrapper').should('not.have.class', 'closed');
cy.get(`#${mountId}`).find('.vscomp-options-container').should('exist');
});

it('does not throw when a wrapper has no associated instance (guard)', () => {
cy.visit('get-started');
cy.window().then((win) => {
const doc = win.document;
const orphan = doc.createElement('div');
orphan.innerHTML = '<div class="vscomp-ele-wrapper"></div>'; // parent has no .virtualSelect
doc.body.appendChild(orphan);

// would throw "Cannot read properties of undefined (reading 'onResize')" without the guard
win.dispatchEvent(new win.Event('resize'));
});

// if the handler threw, Cypress would have failed the test on the uncaught exception
cy.get('body').should('exist');
});
});
50 changes: 50 additions & 0 deletions cypress/e2e/secure-text-warning.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** cSpell:ignore vscomp securetext */

// Tests for S1: when enableSecureText is disabled (default), option text is rendered as raw HTML.
// The plugin should log a single (per page) console warning so the XSS trade-off is discoverable,
// without changing the default (kept off for large-dataset performance).

describe('Security: enableSecureText one-time warning (S1)', () => {
const mount = (win: Window, id: string, extra: Record<string, unknown> = {}) => {
const doc = win.document;
const existing = doc.getElementById(id);
if (existing) {
existing.remove();
}
const $ele = doc.createElement('div');
$ele.id = id;
doc.body.appendChild($ele);
// @ts-expect-error - VirtualSelect attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options: [{ label: 'A', value: 'a' }], ...extra });
return $ele;
};

it('warns once (not per instance) when enableSecureText is disabled', () => {
cy.visit('get-started');
cy.window().then((win) => {
// reset the one-time flag so the assertion is deterministic regardless of demo dropdowns
// @ts-expect-error - static flag
win.VirtualSelect.secureTextWarningShown = false;
cy.spy(win.console, 'warn').as('warn');

mount(win, 'vs-s1-a');
mount(win, 'vs-s1-b'); // second instance must NOT warn again
});

cy.get('@warn').its('callCount').should('eq', 1);
cy.get('@warn').its('firstCall.args.0').should('include', 'enableSecureText');
});

it('does not warn when enableSecureText is enabled', () => {
cy.visit('get-started');
cy.window().then((win) => {
// @ts-expect-error - static flag
win.VirtualSelect.secureTextWarningShown = false;
cy.spy(win.console, 'warn').as('warnEnabled');

mount(win, 'vs-s1-c', { enableSecureText: true });
});

cy.get('@warnEnabled').should('not.be.called');
});
});
58 changes: 58 additions & 0 deletions cypress/e2e/security-classnames-xss.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** cSpell:ignore vscomp classnames */

// Regression test for S3: option `classNames` was concatenated into the class attribute
// without sanitization, allowing attribute-breakout injection. Sanitization must strip the
// breakout characters (" < >) while leaving legitimate class names untouched.

describe('Security: classNames attribute-injection (S3)', () => {
const mountId = 'vs-s3-classnames';

const mount = (win: Window, options: unknown[]) => {
const doc = win.document;
const existing = doc.getElementById(mountId);
if (existing) {
existing.remove();
}

const $ele = doc.createElement('div');
$ele.id = mountId;
doc.body.appendChild($ele);

// @ts-expect-error - VirtualSelect is attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options });
return $ele;
};

it('does not inject markup from a malicious classNames value', () => {
cy.visit('get-started');
cy.window().then((win) => {
// @ts-expect-error - test marker
win.__vsXssS3 = undefined;
mount(win, [{ label: 'A', value: 'a', classNames: 'foo"><img src=x onerror="window.__vsXssS3=true' }]);
});

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();

// the option still renders as a normal vscomp-option (no broken markup)...
cy.get(`#${mountId}`).find('.vscomp-option[data-value="a"]').should('have.class', 'vscomp-option');
// ...but no element was injected and the handler never ran.
cy.get('img[src="x"]').should('not.exist');
cy.window().then((win) => {
// @ts-expect-error - test marker
expect(win.__vsXssS3, 'XSS payload should not execute').to.not.eq(true);
});
});

it('preserves normal class names (no regression for non-malicious input)', () => {
cy.visit('get-started');
cy.window().then((win) => {
mount(win, [{ label: 'B', value: 'b', classNames: 'my-custom-class another-class' }]);
});

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`)
.find('.vscomp-option[data-value="b"]')
.should('have.class', 'my-custom-class')
.and('have.class', 'another-class');
});
});
71 changes: 71 additions & 0 deletions cypress/e2e/security-customdata-xss.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** cSpell:ignore vscomp vsele */

// Regression test for S2: customData fields (group_name / description) were interpolated
// into the option aria-label attribute without escaping, allowing an attribute-breakout XSS
// even when enableSecureText was enabled.

describe('Security: customData attribute-injection (S2)', () => {
const mountId = 'vs-s2-xss';
const payload = '"><img src=x onerror="window.__vsXssS2=true">';

const mount = (win: Window, extra: Record<string, unknown> = {}) => {
const doc = win.document;
const existing = doc.getElementById(mountId);
if (existing) {
existing.remove();
}

const $ele = doc.createElement('div');
$ele.id = mountId;
doc.body.appendChild($ele);

// @ts-expect-error - VirtualSelect is attached to window by the bundle
win.VirtualSelect.init({
ele: $ele,
enableSecureText: true,
options: [
{
label: 'Group',
options: [{ label: 'Child', value: 'c1', customData: { group_name: payload, description: payload } }],
},
],
...extra,
});

return $ele;
};

it('does not inject markup from customData and does not execute the payload', () => {
cy.visit('get-started');

cy.window().then((win) => {
// @ts-expect-error - test marker
win.__vsXssS2 = undefined;
mount(win);
});

// open the dropdown so the options (and their aria-label) are rendered
cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();

// the option must render...
cy.get(`#${mountId}`).find('.vscomp-option[data-value="c1"]').should('exist');
// ...but the breakout payload must NOT have created a real <img> element...
cy.get('img[src="x"]').should('not.exist');
// ...and the onerror handler must never have run.
cy.window().then((win) => {
// @ts-expect-error - test marker
expect(win.__vsXssS2, 'XSS payload should not execute').to.not.eq(true);
});
});

it('keeps the aria-label intact (escaped, not broken out of the attribute)', () => {
cy.visit('get-started');
cy.window().then((win) => mount(win));

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`)
.find('.vscomp-option[data-value="c1"]')
.should('have.attr', 'aria-label')
.and('contain', 'Child');
});
});
64 changes: 64 additions & 0 deletions cypress/e2e/timer-cleanup.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/** cSpell:ignore vscomp */

// Tests for P4: pending setTimeout callbacks were not cleared on destroy(), so they could run
// against a destroyed instance. Timers now go through setManagedTimeout and are cleared in destroy().

describe('Hardening: managed timeouts cleared on destroy (P4)', () => {
const mountId = 'vs-p4-timers';

const mount = (win: Window) => {
const doc = win.document;
const existing = doc.getElementById(mountId);
if (existing) {
existing.remove();
}
const $ele = doc.createElement('div');
$ele.id = mountId;
doc.body.appendChild($ele);
// @ts-expect-error - VirtualSelect attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }] });
return $ele.virtualSelect;
};

it('cancels a pending managed timeout when the instance is destroyed', () => {
cy.visit('get-started');
cy.window().then((win) => {
// @ts-expect-error - test marker
win.__managedTimerFired = false;
const instance = mount(win);
instance.setManagedTimeout(() => {
// @ts-expect-error - test marker
win.__managedTimerFired = true;
}, 50);
instance.destroy();
});

cy.wait(150);
cy.window().then((win) => {
// @ts-expect-error - test marker
expect(win.__managedTimerFired, 'managed timeout must not fire after destroy').to.eq(false);
});
});

it('does not throw when destroying right after an option select (no regression)', () => {
cy.visit('get-started');
cy.window().then((win) => mount(win));

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`).find('.vscomp-option[data-value="a"]').click();

cy.window().then((win) => {
const $ele = win.document.getElementById(mountId);
// @ts-expect-error - instance back-reference
$ele.virtualSelect.destroy();
});

cy.wait(50);
cy.get('body').should('exist');
cy.window().then((win) => {
const $ele = win.document.getElementById(mountId);
// @ts-expect-error - instance back-reference
expect($ele.virtualSelect, 'instance reference cleared on destroy').to.eq(undefined);
});
});
});
Loading