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
40 changes: 22 additions & 18 deletions .github/workflows/development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,49 +10,53 @@ on:
workflow_call:

jobs:
test:
name: Run unit tests
lint:
name: Run linter
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: "☁️ checkout repository"
uses: actions/checkout@v4

- uses: pnpm/action-setup@v3
- name: "🔧 setup pnpm"
uses: pnpm/action-setup@v3
with:
version: 9

- uses: actions/setup-node@v4
- name: "🔧 setup node"
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"

- name: "📦 install dependencies"
run: pnpm install

- name: "🔍 run tests"
run: pnpm test
- name: "🔍 lint code"
run: pnpm lint

lint:
name: Run linter
test:
needs: lint
name: Run unit tests
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 60
steps:
- name: "☁️ checkout repository"
uses: actions/checkout@v4
- uses: actions/checkout@v4

- name: "🔧 setup pnpm"
uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v3
with:
version: 9

- name: "🔧 setup node"
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"

- name: "📦 install dependencies"
run: pnpm install

- name: "🔍 lint code"
run: pnpm lint
- name: "🎭 install playwright"
run: pnpx playwright install --with-deps --only-shell

- name: "🔍 run tests"
run: pnpm test
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,9 @@ out-tsc
# Vitepress
docs/.vitepress/dist
docs/.vitepress/cache

# Playwright
test-results/
playwright-report/
blob-report/
playwright/.cache/
2 changes: 1 addition & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import vitepressConfig from '../vitepress.config';
import vitepressConfig from "../vitepress.config.ts";

export default vitepressConfig;
4 changes: 2 additions & 2 deletions docs/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import DefaultTheme from "vitepress/theme";
import defaultTheme from "vitepress/theme";
import "../../../packages/theme/src/index.css";
import "./custom.css";

export default {
extends: DefaultTheme,
extends: defaultTheme,
};
17 changes: 5 additions & 12 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import jsLint from "@eslint/js";
import commentsPlugin from "eslint-plugin-eslint-comments";
import importPlugin from "eslint-plugin-import";
import jestPlugin from "eslint-plugin-jest";
import jestDomPlugin from "eslint-plugin-jest-dom";
import litPlugin from "eslint-plugin-lit";
import playwrightPlugin from "eslint-plugin-playwright";
import prettierRecommendedConfig from "eslint-plugin-prettier/recommended";
import testingLibraryPlugin from "eslint-plugin-testing-library";
import wcPlugin from "eslint-plugin-wc";
import { config, configs as tsLintConfigs } from "typescript-eslint";

Expand All @@ -20,6 +18,9 @@ export default config(
{
files: ["*.ts", "*.tsx"],
},
{
ignores: ["dist", "node_modules", "docs/.vitepress/cache"],
},
{
languageOptions: {
parserOptions: {
Expand Down Expand Up @@ -47,15 +48,7 @@ export default config(
},
{
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"],
extends: [
jestDomPlugin.configs["flat/recommended"],
testingLibraryPlugin.configs["flat/react"],
testingLibraryPlugin.configs["flat/dom"],
jestPlugin.configs["flat/recommended"],
],
rules: {
"jest/prefer-importing-jest-globals": "error",
},
extends: [playwrightPlugin.configs["flat/recommended"]],
},
{
rules: {
Expand Down
33 changes: 33 additions & 0 deletions internals/test-helpers/create-promise-resolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const createPromiseResolvers = <T = void>() => {
const noop = () => void 0;

let _resolve: (value: T | PromiseLike<T>) => void = noop;
let _reject: (reason?: unknown) => void = noop;

const createPromise = () =>
new Promise<T>((res, rej) => {
_resolve = res;
_reject = rej;
});

let _promise = createPromise();

const renew = () => {
_promise = createPromise();
};

return {
get promise() {
return _promise;
},
get resolve() {
return _resolve;
},
get reject() {
return _reject;
},
renew,
};
};

export default createPromiseResolvers;
10 changes: 10 additions & 0 deletions internals/test-helpers/forEachLocator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type Locator } from "@playwright/test";

export const forEachLocator = async (
locators: Locator[],
callback: (locator: Locator, index: number) => void | Promise<void>,
) => {
for (const [index, locator] of locators.entries()) {
await callback(locator, index);
}
};
9 changes: 9 additions & 0 deletions internals/test-helpers/handles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Page } from "@playwright/test";

export const windowHandle = (page: Page) => page.evaluateHandle(() => window);

export const documentHandle = (page: Page) =>
page.evaluateHandle(() => document);

export const bodyHandle = (page: Page) =>
page.evaluateHandle(() => document.body);
31 changes: 28 additions & 3 deletions internals/test-helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
export { act, render } from "@testing-library/react";
export { default as userEvent } from "@testing-library/user-event";
export * from "shadow-dom-testing-library";
import { test as base } from "@playwright/test";

const test = base.extend({
page: async ({ page }, use) => {
await page.goto("/test");
await use(page);
},
});

const { afterAll, afterEach, beforeAll, beforeEach, describe, expect, step } =
test;

export { default as createPromiseResolvers } from "./create-promise-resolvers.ts";
export * from "./forEachLocator.ts";
export * from "./handles.ts";
export * from "./mock/index.ts";
export * from "./render.ts";

export {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
step,
test,
};
2 changes: 2 additions & 0 deletions internals/test-helpers/mock/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const globalMockReferenceKey = "__global_mock_ref__";
export const mockNamespaces = ["events"] as const;
159 changes: 159 additions & 0 deletions internals/test-helpers/mock/events-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { Locator, Page } from "@playwright/test";
import { globalMockReferenceKey } from "./constants.ts";
import type { MockFn } from "./types.ts";

export type EventsMockState = Record<string, Array<[MockFn, AbortController]>>;

const attachMockedEvent = async (
locator: Locator,
eventName: string,
mockFn: MockFn,
) => {
await locator.evaluateHandle(
(element, [gmrk, eventName, mockFn]) => {
if (!element) {
throw new Error(
"Expected an element with the specified locator to be found on the page.",
);
}

const globalMock = window[gmrk];

if (!globalMock) {
throw new Error(
[
"Global mock object not found.",
"Ensure that the global mock object is properly initialized.",
].join(" "),
);
}

if (!globalMock.events) {
throw new Error(
[
"Global events mock object not found.",
"Ensure that the global events mock object is properly initialized.",
].join(" "),
);
}

const listeners = globalMock.events[eventName] ?? [];
const abortController = new AbortController();

globalMock.events[eventName] = listeners;

element.addEventListener(
eventName,
(...args) => {
mockFn.called = true;
mockFn.callCount += 1;
mockFn.calls.push(args);
},
{ signal: abortController.signal },
);

globalMock.events[eventName].push([mockFn, abortController]);
globalMock.mockFns.push(mockFn);
},
[globalMockReferenceKey, eventName, mockFn] as const,
);
};

const detachMockedEvent = async (
locator: Locator,
eventName: string,
mockFn: MockFn,
) => {
await locator.evaluateHandle(
(element, [gmrk, eventName, mockFn]) => {
if (!element) {
throw new Error(
[
"Element not found.",
"Expected an element with the specified locator to be present on the page.",
].join(" "),
);
}

const globalMock = window[gmrk];

if (!globalMock) {
throw new Error(
[
"Global mock object not found.",
"Ensure that the global mock object is properly initialized.",
].join(" "),
);
}

if (!globalMock.events) {
throw new Error(
[
"Global events mock object not found.",
"Ensure that the global events mock object is properly initialized.",
].join(" "),
);
}

const listeners = globalMock.events[eventName];

if (!listeners) {
throw new Error(
[
"Event list not found.",
`No event listener found for the event name "${eventName}".`,
].join(" "),
);
}

const entityIdx = listeners.findIndex(
mockEntity => mockEntity[0] === mockFn,
);

if (entityIdx === -1) {
throw new Error(
[
"Mock function not found.",
"The specified mock function is not registered in the event set.",
].join(" "),
);
}

const entity = listeners[entityIdx]!;

entity[1].abort();
listeners.splice(entityIdx, 1);

const idx = globalMock.mockFns.findIndex(fn => fn === mockFn);

if (idx === -1) return;

globalMock.mockFns.splice(idx, 1);
},
[globalMockReferenceKey, eventName, mockFn] as const,
);
};

export const initNamespace = async (page: Page) => {
await page.evaluate(gmrk => {
const globalMock = window[gmrk as typeof globalMockReferenceKey];

if (!globalMock) {
throw new Error(
[
"Global mock object not found.",
"Ensure that the global mock object is properly initialized.",
].join(" "),
);
}

globalMock.events = {} as EventsMockState;
}, globalMockReferenceKey);
};

export const setup = () => {
return {
attachMockedEvent,
detachMockedEvent,
};
};
Loading