Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
"eslint-plugin-unicorn": "^73.0.0",
"globals": "^17.9.0",
"happy-css-modules": "^5.0.2",
"happy-dom": "^20.11.2",
"husky": "^9.1.6",
"jsdom": "^30.0.1",
"lint-staged": "^17.3.0",
"mini-css-extract-plugin": "^2.10.2",
"postcss": "^8.5.25",
Expand Down
2 changes: 1 addition & 1 deletion packages/vkui-floating-ui/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'jsdom',
environment: 'happy-dom',
include: [path.resolve(__dirname, '*.test.mjs'), path.resolve(__dirname, '*.test.cjs')],
globalSetup: path.resolve(__dirname, 'main.mjs'),
globals: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,12 @@ function vitestWorkaroundGetOverscrollBehaviorPropertyValue(el: HTMLElement) {
function getStyleAttributeObject(el: HTMLElement | null) {
const style = el ? el.getAttribute('style') : null;

// В happy-dom очистка всех инлайн-стилей удаляет атрибут `style`, поэтому
// `getAttribute('style')` возвращает `null`. jsdom в этом случае оставляет
// пустой атрибут (`''`). Возвращаем `{}` для очищенного стиля, чтобы совпадать
// с поведением jsdom, на которое рассчитаны ожидания тестов.
if (style === null) {
return null;
return el && el.style.cssText === '' ? {} : null;
}

return Object.fromEntries(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ describe(useNativeFormResetListener, () => {
fireEvent.click(result.getByTestId('reset'));

expect(customFormItemLocator).toHaveTextContent('');
// В happy-dom нативные события не помечаются `isTrusted: true` (как в jsdom),
// поэтому проверяем только тип события, а не его "нативность".
expect(hookCallback).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
isTrusted: true,
type: 'reset',
}),
);
});
Expand Down
9 changes: 8 additions & 1 deletion packages/vkui/src/testing/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,14 @@ export const fireEventPatch = async <E extends EventType>(
case 'mouseLeave':
fireEvent[eventType](el);
if (options === undefined || options === true) {
await act(async () => await waitRAF());
await act(async () => {
await waitRAF();
// В happy-dom `requestAnimationFrame` отрабатывает раньше `setTimeout(0)`,
// которым пользуется `debounce` (hover-show/hide). Без macrotask-wait
// отложенный `commitShownLocalState` срабатывает вне `act`, и React не
// применяет обновление состояния.
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
break;
case 'animationStart':
Expand Down
6 changes: 3 additions & 3 deletions packages/vkui/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ const config = defineConfig({
test: {
name: 'unit',
include: ['**/*.test.tsx', '**/*.test.ts'],
environment: 'jsdom',
setupFiles: [resolve(__dirname, 'vitest.setup.ts')],
globalSetup: [resolve(__dirname, 'vitest.global-setup.ts')],
environment: 'happy-dom',
setupFiles: [resolve(import.meta.dirname, 'vitest.setup.ts')],
globalSetup: [resolve(import.meta.dirname, 'vitest.global-setup.ts')],
coverage: {
provider: 'v8',
reporter: ['json', 'cobertura'],
Expand Down
177 changes: 170 additions & 7 deletions packages/vkui/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { noop } from '@vkontakte/vkjs';
import { afterEach, beforeEach, vi } from 'vitest';
import failOnConsole from 'vitest-fail-on-console';

// В этом окружении jsdom не предоставляет `localStorage`, а Node отдаёт
// экспериментальный геттер, который при каждом доступе печатает
// `ExperimentalWarning: localStorage is not available ...`. Подменим его
// В этом окружении `localStorage` может быть недоступен или вести себя
// нестабильно (экспериментальный геттер Node печатает
// `ExperimentalWarning: localStorage is not available ...`). Подменим его
// рабочей in-memory реализацией — это убирает предупреждение (в т.ч. когда
// chai инспектирует `window` при форматировании сообщений утверждений) и даёт
// тестам реальный `localStorage`.
Expand Down Expand Up @@ -66,6 +66,21 @@ beforeEach(() => {
).IS_REACT_ACT_ENVIRONMENT = true;
});

// Между тестами сбрасываем отложенные real-`setTimeout(0)` от
// `useRestoreFocus`/`useAutoFocus`. Без этого pending-колбэки срабатывают во
// время следующего теста (real-таймеры не отменяются `vi.useFakeTimers`) и
// ломают фокус в happy-dom. Регистрируем ДО `cleanup`: vitest выполняет
// `afterEach` в LIFO-порядке, поэтому этот flush отработает ПОСЛЕ unmount —
// `useAutoFocus` уже отменил свой таймер (`clearTimeout`), а колбэк
// `useRestoreFocus` сработает на удалённом элементе как no-op.
// Пропускаем, если активны fake-таймеры: `setTimeout(0)` под fake никогда не
// разрешится без `vi.runAllTimers()` и подвесит `afterEach`.
afterEach(async () => {
if (!vi.isFakeTimers()) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});

// Регистрируем после failOnConsole, чтобы cleanup выполнялся первым даже если
// проверка console.error в afterEach завершится ошибкой.
afterEach(cleanup);
Expand Down Expand Up @@ -100,7 +115,53 @@ globalThis.getComputedStyle = function (el: Element, pseudoElt?: string | null)
}

// иначе — дефолтное поведение
return _origGetComputedStyle.call(globalThis, el);
const computed = _origGetComputedStyle.call(globalThis, el);
const inlineStyle = el instanceof HTMLElement ? el.style : null;

// У happy-dom есть два отличия от jsdom, влияющих на тесты:
//
// 1. `getComputedStyle` вычисляется только для элементов, подключённых к
// `document` в момент вызова; для detached-элементов возвращается пустой
// `CSSStyleDeclaration` (без инлайн-стилей и дефолтных значений). jsdom
// возвращал инлайн-стили независимо от подключения.
//
// 2. happy-dom резолвит `var()`-ссылки в значениях custom properties: если
// целевая переменная не определена, `getPropertyValue('--x')` возвращает
// пустую строку, хотя инлайн-стиль хранил литерал `var(--y)`. jsdom
// возвращал литерал неразрешённым — на это рассчитаны тесты, проверяющие,
// что компонент прокинул `var(...)` в инлайн-стиль.
//
// Проксируем результат, восстанавливая поведение jsdom: при чтении свойства
// сначала отдаём подходящий инлайн-стиль, затем — значение из `computed`.
return new Proxy<CSSStyleDeclaration>(computed, {
get(target, prop, receiver) {
if (prop === 'getPropertyValue') {
return (property: string) => {
const inline = inlineStyle?.getPropertyValue(property) ?? '';
// Для custom property с `var()` отдаём сырой литерал, как jsdom.
if (property.startsWith('--') && inline.includes('var(')) {
return inline;
}
// Для detached-элементов инлайн — единственный источник значений.
if (!el.isConnected && inline) {
return inline;
}
return target.getPropertyValue(property);
};
}
if (prop === 'length' || prop === 'item' || prop === Symbol.toPrimitive) {
return Reflect.get(target, prop, receiver);
}
// Прямой доступ к свойству (например `cs.transform`) для detached-элементов.
if (!el.isConnected) {
const inline = inlineStyle?.getPropertyValue(String(prop)) ?? '';
if (inline) {
return inline;
}
}
return Reflect.get(target, prop, receiver);
},
});
};

Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
Expand Down Expand Up @@ -132,7 +193,7 @@ Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
}),
});

// Не реализован в JSDOM.
// Не реализован в тестовом окружении.
// https://jestjs.io/docs/manual-mocks
class DOMRectPolyfill {
x = 0;
Expand Down Expand Up @@ -163,7 +224,7 @@ class DOMRectPolyfill {
}
vi.stubGlobal('DOMRect', DOMRectPolyfill);

// Не реализован в JSDOM.
// Не реализован в тестовом окружении.
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
Expand All @@ -181,7 +242,7 @@ vi.stubGlobal(
vi.stubGlobal('scrollTo', vi.fn());
Element.prototype.scrollTo = vi.fn();

// Mock ResizeObserver for JSDOM
// Mock ResizeObserver for testing environment
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
Expand All @@ -205,5 +266,107 @@ class FakeTransitionEvent extends Event {
// Подменяем глобально
vi.stubGlobal('TransitionEvent', FakeTransitionEvent);

// happy-dom не полностью реализует ARIA IDL-отражение (`ARIAMixin`): свойства
// вида `element.ariaDisabled` не синхронизированы с одноимёнными атрибутами
// `aria-disabled`, а `element.role` без атрибута возвращает `''` вместо `null`.
// jsdom это поддерживает, и тесты полагаются на `el.ariaDisabled === 'true'`
// и `el.role === null`. Восстанавливаем поведение через геттеры/сеттеры.
const ARIA_PROPS: ReadonlyArray<readonly [string, string]> = [
['ariaAtomic', 'aria-atomic'],
['ariaAutoComplete', 'aria-autocomplete'],
['ariaBrailleLabel', 'aria-braillelabel'],
['ariaBrailleRoleDescription', 'aria-brailleroledescription'],
['ariaBusy', 'aria-busy'],
['ariaChecked', 'aria-checked'],
['ariaColCount', 'aria-colcount'],
['ariaColIndex', 'aria-colindex'],
['ariaColSpan', 'aria-colspan'],
['ariaCurrent', 'aria-current'],
['ariaDescription', 'aria-description'],
['ariaDisabled', 'aria-disabled'],
['ariaExpanded', 'aria-expanded'],
['ariaHasPopup', 'aria-haspopup'],
['ariaHidden', 'aria-hidden'],
['ariaInvalid', 'aria-invalid'],
['ariaKeyShortcuts', 'aria-keyshortcuts'],
['ariaLabel', 'aria-label'],
['ariaLevel', 'aria-level'],
['ariaLive', 'aria-live'],
['ariaModal', 'aria-modal'],
['ariaMultiLine', 'aria-multiline'],
['ariaMultiSelectable', 'aria-multiselectable'],
['ariaOrientation', 'aria-orientation'],
['ariaPlaceholder', 'aria-placeholder'],
['ariaPosInSet', 'aria-posinset'],
['ariaPressed', 'aria-pressed'],
['ariaReadOnly', 'aria-readonly'],
['ariaRelevant', 'aria-relevant'],
['ariaRequired', 'aria-required'],
['ariaRoleDescription', 'aria-roledescription'],
['ariaRowCount', 'aria-rowcount'],
['ariaRowIndex', 'aria-rowindex'],
['ariaRowSpan', 'aria-rowspan'],
['ariaSelected', 'aria-selected'],
['ariaSetSize', 'aria-setsize'],
['ariaSort', 'aria-sort'],
['ariaValueMax', 'aria-valuemax'],
['ariaValueMin', 'aria-valuemin'],
['ariaValueNow', 'aria-valuenow'],
['ariaValueText', 'aria-valuetext'],
] as const;

const defineReflectedAttr = (proto: object, idlName: string, attrName: string): void => {
Object.defineProperty(proto, idlName, {
configurable: true,
enumerable: true,
get(this: Element): string | null {
return this.getAttribute(attrName);
},
set(this: Element, value: string | null): void {
if (value === null) {
this.removeAttribute(attrName);
} else {
this.setAttribute(attrName, value);
}
},
});
};

for (const [idlName, attrName] of ARIA_PROPS) {
defineReflectedAttr(Element.prototype, idlName, attrName);
}
// `role` отражается как `element.role` (null при отсутствии атрибута).
defineReflectedAttr(Element.prototype, 'role', 'role');

// happy-dom `HTMLFormElement.reset()` восстанавливает `defaultValue` для `<input>`,
// но не возвращает `<select>` к option'у с `defaultSelected`. jsdom это делает.
// Восстанавливаем вручную после нативного `reset`.
// eslint-disable-next-line @typescript-eslint/unbound-method
const _origFormReset = HTMLFormElement.prototype.reset;
HTMLFormElement.prototype.reset = function (this: HTMLFormElement): void {
_origFormReset.call(this);
// eslint-disable-next-line no-restricted-properties
for (const sel of Array.from(this.querySelectorAll('select'))) {
const defaultOption = Array.from(sel.options).find((opt) => opt.defaultSelected);
if (defaultOption) {
sel.value = defaultOption.value;
} else if (sel.options.length > 0) {
sel.selectedIndex = 0;
}
}
};

// happy-dom 20 помечает `HTMLImageElement.complete = true` сразу после рендера
// `<img src>` (без реальной загрузки), из-за чего `ImageBase` считает картинку
// уже загруженной и диспатчит `load` автоматически. jsdom и happy-dom 18
// возвращали `false` для незагруженной картинки. Возвращаем `false` по умолчанию
// (тесты, которым нужно `complete=true`, мокают его через `defineProperty`).
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
configurable: true,
get(this: HTMLImageElement): boolean {
return false;
},
});

// Замена vitest.global-setup.ts
vi.stubEnv('TZ', 'UTC');
Loading
Loading