Skip to content
Draft
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
14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@
"scripts": {
"dev": "storybook dev -p 3001 --no-open",
"build:docs": "storybook build --output-dir docs",
"build": "node scripts/build.js"
"build": "node scripts/build.js",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
},
"engines": {
"node": ">=14.17.0"
Expand All @@ -59,14 +62,21 @@
"@storybook/addon-links": "^9.0.18",
"@storybook/web-components-vite": "^9.0.18",
"@teamhanko/hanko-elements": "^2.1.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.7.0",
"@vitest/coverage-v8": "^3.2.4",
"@whitespace/storybook-addon-html": "^7.0.0",
"class-variance-authority": "^0.7.1",
"del": "^8.0.0",
"esbuild": "^0.25.8",
"globby": "^14.1.0",
"jsdom": "^27.0.0",
"lit": "^3.3.1",
"storybook": "^9.0.18",
"typescript": "^5.8.3",
"user-agent-data-types": "^0.4.2"
"user-agent-data-types": "^0.4.2",
"vitest": "^3.2.4"
}
}
1,124 changes: 1,059 additions & 65 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

116 changes: 116 additions & 0 deletions tests/components/header.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/// <reference types="vitest" />
import { describe, it, expect } from 'vitest'
import { within } from '@testing-library/dom'


import '../../src/components/header/header.ts'


describe('<hot-header>', () => {
it('is registered as a custom element', () => {
const ctor = customElements.get('hot-header')
expect(ctor).toBeTruthy()
})

it('applies size="small" by adding the correct class on <header>', async () => {
const el = document.createElement('hot-header') as HTMLElement & { shadowRoot: ShadowRoot, updateComplete?: Promise<unknown> }
el.setAttribute('size', 'small')
document.body.appendChild(el)

// Lit renders asynchronously
if (el.updateComplete) await el.updateComplete

const sr = el.shadowRoot!
// @testing-library types want an HTMLElement; cast is fine for ShadowRoot
const q = within(sr as unknown as HTMLElement)

// The CSS uses class-variance-authority with a `header` element and
// a variant class like "header--size-small".
const headerEl = sr.querySelector('header') as HTMLElement | null
expect(headerEl).toBeTruthy()
expect(headerEl!.className).toMatch(/\bheader--size-small\b/)
})

it('selectTab updates selectedTab and ignores invalid/same index', async () => {
// Create the element and set up minimal state
const el = document.createElement('hot-header') as HTMLElement & {
shadowRoot: ShadowRoot
updateComplete?: Promise<unknown>
tabs?: unknown[]
selectedTab?: number
selectTab: (i: number) => void
}
document.body.appendChild(el)
if (el.updateComplete) await el.updateComplete

// Provide tabs so selectTab has bounds to check
el.tabs = ['Overview', 'Data', 'Settings']
el.selectedTab = 0

// valid change
el.selectTab(1)
expect(el.selectedTab).toBe(1)

// same index → ignored
el.selectTab(1)
expect(el.selectedTab).toBe(1)

// out-of-range → ignored
el.selectTab(-1)
expect(el.selectedTab).toBe(1)
el.selectTab(99)
expect(el.selectedTab).toBe(1)
})

it('dispatches "tab-change" with the right detail', () => {
const el = document.createElement('hot-header') as any;
document.body.appendChild(el);
el.tabs = [
{ label: 'Overview', clickEvent: () => {} },
{ label: 'Data', clickEvent: () => {} }
];
el.selectedTab = 0;

const spy = vi.fn();
el.addEventListener('tab-change', (e: any) => spy(e.detail));

el.selectTab(1);

expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
selectedIndex: 1,
previousIndex: 0,
tab: expect.objectContaining({ label: 'Data' })
})
);
expect(el.activeTabIndex).toBe(1);
});

it('opens the login dialog and emits "login" on click', async () => {
document.body.innerHTML = `<hot-header title="Test" showLogin="true"></hot-header>`;
const el = document.querySelector('hot-header') as any;
if (el.updateComplete) await el.updateComplete;

// listen for the public event
const fired = vi.fn();
el.addEventListener('login', fired);

// trigger the same code path as a click on the login button
el._handleLogin(); // call the component method directly
if (el.updateComplete) await el.updateComplete;

// event fired
expect(fired).toHaveBeenCalled();

// dialog should now be open (property OR attribute depending on binding/stub)
const sr = el.shadowRoot!;
const dialog = sr.querySelector('wa-dialog.login-modal') as HTMLElement & { open?: boolean };
const isOpen = (dialog?.open === true) || dialog?.hasAttribute('open');
expect(isOpen).toBe(true);
});





})
39 changes: 39 additions & 0 deletions tests/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { vi } from 'vitest'
vi.mock('src/components/icons.ts', () => ({
__esModule: true,
default: () => {}, // if default import is used
registerBundledIcons: () => {}, // named export used by header
tryRegister: () => {}, // extra safety if referenced
}))
// Optional: if your source sometimes imports without ".ts", mock that too.
vi.mock('src/components/icons', () => ({
__esModule: true,
default: () => {},
registerBundledIcons: () => {},
tryRegister: () => {},
}))
// Mock the theme injector fully (add setupAutoInjection!)
vi.mock('src/utils/shadow-dom-css.ts', () => ({
setupAutoInjection: () => {},
injectCSSIntoShadowDOM: () => {},
injectHOTThemeIntoButtons: () => {},
injectHOTThemeIntoAllComponents: () => {},
}))

// (Optional) if your source sometimes imports without ".ts", mock that too.
vi.mock('src/utils/shadow-dom-css', () => ({
setupAutoInjection: () => {},
injectCSSIntoShadowDOM: () => {},
injectHOTThemeIntoButtons: () => {},
injectHOTThemeIntoAllComponents: () => {},
}))

vi.mock('src/utils/hanko.ts', () => ({
registerHanko: async () => {
// define minimal custom elements so template can render without errors
if (!customElements.get('hanko-auth')) customElements.define('hanko-auth', class extends HTMLElement {})
if (!customElements.get('hanko-profile')) customElements.define('hanko-profile', class extends HTMLElement {})
return
},
}))

51 changes: 51 additions & 0 deletions tests/polyfills.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you comment for why we need polyfills here? 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, I had added the polyfills and mocks earlier just to test the logic and make sure the Vitest setup was working properly in jsdom.
I meant to remove them afterward and thought I already had, but looks like I missed that.
They’re no longer needed I just reran everything, and the tests pass cleanly without both. I’ll remove them in the next commit to keep things tidy.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, just to be transparent I wrote around 80% of the code myself.
I used AI mostly to help debug issues, refine TypeScript configs, and test small snippets

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the heads up! Sounds good =)

Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// ----- Robust ElementInternals polyfill for jsdom -----
declare global {
// minimal typing so TS is happy
interface ElementInternals {}
}

const makeInternals = () => {
const states = new Set<string>();
return {
setValidity: (_? : any, __?: any, ___?: any) => {},
setFormValue: (_?: any, __?: any) => {},
reportValidity: () => true,
checkValidity: () => true,
// mimic the "states" tokenlist-ish API that some libs poke
states: {
add: (s: string) => { states.add(s); },
delete: (s: string) => { states.delete(s); },
has: (s: string) => states.has(s),
clear: () => { states.clear(); },
// iterable shape not required for our tests
}
} as any;
};

if (!(globalThis as any).ElementInternals) {
(globalThis as any).ElementInternals = function ElementInternals() {} as any;
}

// Always provide attachInternals
(HTMLElement.prototype as any).attachInternals = function () {
// cache per element
if (!(this as any).__internals__) {
(this as any).__internals__ = makeInternals();
}
return (this as any).__internals__;
};

// Some libs read/write `this.internals` directly.
// Provide a getter/setter that falls back to our stub.
if (!Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'internals')) {
Object.defineProperty(HTMLElement.prototype, 'internals', {
get() {
return (this as any).__internals__ ?? ((this as any).__internals__ = makeInternals());
},
set(v) {
(this as any).__internals__ = v ?? makeInternals();
},
configurable: true,
});
}
// ----- end polyfill -----
11 changes: 11 additions & 0 deletions tests/sanity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// tests/sanity.spec.ts
/// <reference types="vitest" />

describe('vitest setup', () => {
it('can manipulate the DOM', () => {
document.body.innerHTML = `<div id="hello">Hi</div>`
const el = document.getElementById('hello')
expect(el).not.toBeNull()
expect(el?.textContent).toBe('Hi')
})
})
48 changes: 48 additions & 0 deletions tests/setupTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// tests/setupTests.ts

import './polyfills' // our polyfills/mocks; must be first
import './mocks' // our module mocks



// ----- Stub WebAwesome custom elements in tests -----
const realDefine = customElements.define.bind(customElements);

customElements.define = ((name: string, ctor: CustomElementConstructor, options?: ElementDefinitionOptions) => {
// Short-circuit any attempt to (re)define wa-*; we just keep our no-op versions
if (name.startsWith('wa-')) {
if (!customElements.get(name)) {
// lightweight element so headers can render <wa-button>, etc., without lifecycle
realDefine(name, class extends HTMLElement {}, options);
}
return; // ignore the library's real define
}
return realDefine(name, ctor, options);
}) as any;

// Pre-register the ones likely used by <hot-header>
['wa-button','wa-icon','wa-badge','wa-dropdown','wa-tooltip','wa-spinner','wa-menu','wa-menu-item','wa-tab','wa-tabs']
.forEach(tag => { if (!customElements.get(tag)) customElements.define(tag, class extends HTMLElement {}); });
// ----- end stubs -----












import '@testing-library/jest-dom'
import { configure } from '@testing-library/dom'

// Slightly shorter async timeout for faster failures
configure({ asyncUtilTimeout: 2000 })

// Optional: stubs for browser APIs some components might use
class NoopObserver { observe(){} unobserve(){} disconnect(){} }
;(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? NoopObserver
;(globalThis as any).IntersectionObserver = (globalThis as any).IntersectionObserver ?? NoopObserver
6 changes: 3 additions & 3 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"lib": [
"dom",
"dom.Iterable",
"es2020"
"es2020" , "ES2022", "DOM"
],
"emitDeclarationOnly": true,
"declaration": true,
Expand All @@ -31,10 +31,10 @@
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": [
"user-agent-data-types"
"user-agent-data-types" , "vitest/globals", "@testing-library/jest-dom", "node"
]
},
"include": [
"src/**/*"
"src/**/*", "tests", "vitest.config.ts"
]
}
22 changes: 22 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'node:path'

export default defineConfig({
resolve: {
alias: {
src: path.resolve(__dirname, 'src')
}
},

test: {
environment: 'jsdom', // so DOM APIs exist in tests
globals: true, // enables global expect/test/etc.
setupFiles: ['./tests/setupTests.ts'],
include: ['tests/**/*.spec.*', 'tests/**/*.test.*', 'src/**/*.{spec,test}.*'],
coverage: {
reporter: ['text', 'lcov'],
reportsDirectory: './coverage'
}
}
})