From fc3319e95deaa6e94b236ea9e1b5087a29297b96 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 20 May 2026 19:40:35 +0200 Subject: [PATCH 1/6] replace Inrupt by Uvdsl OIDC client --- jest.config.mjs | 4 +- src/login/login.ts | 11 ++- src/v2/components/footer/Footer.ts | 3 - src/v2/components/loginButton/LoginButton.ts | 5 +- test/mocks/solid-oidc-client-browser.ts | 73 ++++++++++++++++++++ tsconfig.json | 6 +- 6 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 test/mocks/solid-oidc-client-browser.ts diff --git a/jest.config.mjs b/jest.config.mjs index 407d95975..9861e34bd 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -15,7 +15,9 @@ export default { ], setupFilesAfterEnv: ['./test/helpers/setup.ts'], moduleNameMapper: { - '^.+\\.css$': '/__mocks__/styleMock.js' + '^.+\\.css$': '/__mocks__/styleMock.js', + '^solid-logic$': '/../solid-logic/src', + '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts' }, testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], roots: ['/src', '/test', '/__mocks__'], diff --git a/src/login/login.ts b/src/login/login.ts index b93e07f11..f99175a95 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -513,10 +513,7 @@ export function renderSignInPopup (dom: HTMLDocument) { // Login const locationUrl = new URL(window.location.href) locationUrl.hash = '' // remove hash part - await authSession.login({ - redirectUrl: locationUrl.href, - oidcIssuer: issuerUri - }) + await authSession.login(issuerUri, locationUrl.href) } catch (err) { alert(err.message) } @@ -669,9 +666,9 @@ export function loginStatusBox ( } box.refresh = function () { - const sessionInfo = authSession.info - if (sessionInfo && sessionInfo.webId && sessionInfo.isLoggedIn) { - me = solidLogicSingleton.store.sym(sessionInfo.webId) + const webId = authSession.webId + if (webId) { + me = solidLogicSingleton.store.sym(webId) } else { me = null } diff --git a/src/v2/components/footer/Footer.ts b/src/v2/components/footer/Footer.ts index e3277dc55..efec2c61c 100644 --- a/src/v2/components/footer/Footer.ts +++ b/src/v2/components/footer/Footer.ts @@ -107,9 +107,6 @@ export class Footer extends LitElement { if (typeof authSession.events.off === 'function') { authSession.events.off('login', this._updateFooter) authSession.events.off('logout', this._updateFooter) - } else if (typeof authSession.events.removeListener === 'function') { - authSession.events.removeListener('login', this._updateFooter) - authSession.events.removeListener('logout', this._updateFooter) } super.disconnectedCallback() } diff --git a/src/v2/components/loginButton/LoginButton.ts b/src/v2/components/loginButton/LoginButton.ts index e5758112e..5444c94a6 100644 --- a/src/v2/components/loginButton/LoginButton.ts +++ b/src/v2/components/loginButton/LoginButton.ts @@ -377,10 +377,7 @@ export class LoginButton extends LitElement { const locationUrl = new URL(window.location.href) locationUrl.hash = '' - await authSession.login({ - redirectUrl: locationUrl.href, - oidcIssuer: issuerUri - }) + await authSession.login(issuerUri, locationUrl.href) } catch (err: any) { this._errorMsg = err.message || String(err) this.requestUpdate() diff --git a/test/mocks/solid-oidc-client-browser.ts b/test/mocks/solid-oidc-client-browser.ts new file mode 100644 index 000000000..bebc302e6 --- /dev/null +++ b/test/mocks/solid-oidc-client-browser.ts @@ -0,0 +1,73 @@ +type Listener = (...args: any[]) => void + +class EventEmitterLike { + private listeners: Record = {} + + on (event: string, listener: Listener): void { + const list = this.listeners[event] || [] + list.push(listener) + this.listeners[event] = list + } + + off (event: string, listener: Listener): void { + const list = this.listeners[event] || [] + this.listeners[event] = list.filter(item => item !== listener) + } + + emit (event: string, ...args: any[]): void { + const list = this.listeners[event] || [] + list.forEach(listener => listener(...args)) + } +} + +export class Session { + info: { webId?: string, isLoggedIn: boolean } = { isLoggedIn: false } + webId?: string + isActive = false + events = new EventEmitterLike() + + private eventTarget = new EventTarget() + + addEventListener (type: string, listener: EventListenerOrEventListenerObject | null): void { + if (!listener) return + this.eventTarget.addEventListener(type, listener) + } + + removeEventListener (type: string, listener: EventListenerOrEventListenerObject | null): void { + if (!listener) return + this.eventTarget.removeEventListener(type, listener) + } + + dispatchEvent (event: Event): boolean { + return this.eventTarget.dispatchEvent(event) + } + + async handleIncomingRedirect (): Promise { + + } + + async handleRedirectFromLogin (): Promise { + + } + + async restore (): Promise { + + } + + async login (_idp?: string, _redirectUri?: string): Promise { + } + + async logout (): Promise { + this.info = { isLoggedIn: false } + this.webId = undefined + this.isActive = false + } + + fetch (input: RequestInfo | URL, init?: RequestInit): Promise { + return globalThis.fetch(input, init) + } + + authFetch (input: RequestInfo | URL, init?: RequestInit): Promise { + return globalThis.fetch(input, init) + } +} diff --git a/tsconfig.json b/tsconfig.json index 20ab8849e..babcfa81d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,8 +63,10 @@ "declarations.d.ts" ] /* List of folders to include type definitions from. */, // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "preserveSymlinks": true, /* Do not resolve the real path of symlinks. Needed for local linked solid-logic. */ + "baseUrl": ".", /* Base directory to resolve non-absolute module names. Needed for paths mapping. */ + "paths": { "rdflib": ["./node_modules/rdflib"] }, /* Map rdflib to avoid duplicate type identity when linked with solid-logic. */ /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ From 7039622494c2ff87c6d318fd48f970d8acf7aa96 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 24 May 2026 19:59:36 +0200 Subject: [PATCH 2/6] fix(auth): sync header auth state with session and enforce server logout endpoints 1. derive header auth state from auth session checks/events 2. call end_session and NSS well-known logout on logout 3. add/update header tests for session-driven state transitions --- src/login/login.ts | 6 +++ src/v2/components/header/Header.ts | 62 ++++++++++++++++++++-- src/v2/components/header/header.test.ts | 68 +++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index f99175a95..07817f614 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -713,6 +713,12 @@ authSession.events.on('logout', async () => { await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) } } + + try { + await fetch('/.well-known/solid/logout', { credentials: 'include' }) + } catch (_err) { + // Not all deployments expose NSS-compatible well-known logout endpoint. + } } catch (_err) { // Do nothing } diff --git a/src/v2/components/header/Header.ts b/src/v2/components/header/Header.ts index 0f1774e64..f083f3638 100644 --- a/src/v2/components/header/Header.ts +++ b/src/v2/components/header/Header.ts @@ -1,6 +1,6 @@ import { LitElement, html, css } from 'lit' import { icons } from '../../../iconBase' -import { authSession } from 'solid-logic' +import { authSession, authn } from 'solid-logic' import '../loginButton/index' import '../signupButton/index' import { ifDefined } from 'lit/directives/if-defined.js' @@ -510,6 +510,9 @@ export class Header extends LitElement { declare helpMenuOpen: boolean declare hasSlottedAccountMenu: boolean declare hasSlottedHelpMenu: boolean + private readonly handleAuthSessionChange = () => { + this.refreshAuthStateFromSession() + } constructor () { super() @@ -540,14 +543,34 @@ export class Header extends LitElement { super.connectedCallback() document.addEventListener('click', this.handleDocumentClick) window.addEventListener('keydown', this.handleWindowKeydown) + if (typeof authSession.events?.on === 'function') { + authSession.events.on('login', this.handleAuthSessionChange) + authSession.events.on('logout', this.handleAuthSessionChange) + authSession.events.on('sessionRestore', this.handleAuthSessionChange) + } + this.refreshAuthStateFromSession() } disconnectedCallback () { document.removeEventListener('click', this.handleDocumentClick) window.removeEventListener('keydown', this.handleWindowKeydown) + if (typeof authSession.events?.off === 'function') { + authSession.events.off('login', this.handleAuthSessionChange) + authSession.events.off('logout', this.handleAuthSessionChange) + authSession.events.off('sessionRestore', this.handleAuthSessionChange) + } super.disconnectedCallback() } + private async refreshAuthStateFromSession () { + try { + await authn.checkUser() + } catch (_err) { + // Keep rendering even if session refresh cannot complete. + } + this.authState = authn.currentUser() ? 'logged-in' : 'logged-out' + } + private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) { event.preventDefault() this.helpMenuOpen = false @@ -665,8 +688,8 @@ export class Header extends LitElement { ` } - private handleLoginSuccess () { - this.authState = 'logged-in' + private async handleLoginSuccess () { + await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('auth-action-select', { detail: { role: 'login' }, bubbles: true, @@ -676,12 +699,17 @@ export class Header extends LitElement { private async handleLogout () { this.accountMenuOpen = false + const issuer = window.localStorage.getItem('loginIssuer') || '' + try { await authSession.logout() } catch (_err) { // logout errors are non-fatal — proceed to clear state } - this.authState = 'logged-out' + + await this.performServerLogout(issuer) + + await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('logout-select', { detail: { role: 'logout' }, bubbles: true, @@ -689,6 +717,32 @@ export class Header extends LitElement { })) } + private async performServerLogout (issuer: string) { + // Best-effort server logout for cookie-backed sessions on NSS-like servers. + try { + if (issuer) { + const wellKnownUri = new URL(issuer) + wellKnownUri.pathname = '/.well-known/openid-configuration' + const wellKnownResult = await fetch(wellKnownUri.toString(), { credentials: 'include' }) + + if (wellKnownResult.status === 200) { + const openidConfiguration = await wellKnownResult.json() + if (openidConfiguration && openidConfiguration.end_session_endpoint) { + await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) + } + } + } + } catch (_err) { + // Continue with local logout state even if remote IdP logout is unavailable. + } + + try { + await fetch('/.well-known/solid/logout', { credentials: 'include' }) + } catch (_err) { + // Not all deployments expose NSS-compatible well-known logout. + } + } + private renderAccountMenuItem (item: HeaderAccountMenuItem) { const content = html` ${this.renderLoggedInAvatar(item.avatar, 'account-menu-avatar')} diff --git a/src/v2/components/header/header.test.ts b/src/v2/components/header/header.test.ts index db621f23b..55c4ee282 100644 --- a/src/v2/components/header/header.test.ts +++ b/src/v2/components/header/header.test.ts @@ -1,9 +1,39 @@ import { Header } from './Header' import './index' +import { authn, authSession } from 'solid-logic' + +type Listener = () => void +const mockSessionListeners = new Map>() + +jest.mock('solid-logic', () => ({ + authn: { + checkUser: jest.fn(async () => null), + currentUser: jest.fn(() => null) + }, + authSession: { + logout: jest.fn(async () => undefined), + events: { + on: jest.fn((event: string, handler: Listener) => { + if (!mockSessionListeners.has(event)) mockSessionListeners.set(event, new Set()) + mockSessionListeners.get(event)?.add(handler) + }), + off: jest.fn((event: string, handler: Listener) => { + mockSessionListeners.get(event)?.delete(handler) + }), + emit: jest.fn((event: string) => { + mockSessionListeners.get(event)?.forEach(handler => handler()) + }) + } + } +})) describe('SolidUIHeaderElement', () => { beforeEach(() => { document.body.innerHTML = '' + jest.clearAllMocks() + mockSessionListeners.clear() + ;(authn.currentUser as jest.Mock).mockReturnValue(null) + ;(authn.checkUser as jest.Mock).mockResolvedValue(null) Object.defineProperty(window, 'open', { configurable: true, writable: true, @@ -77,6 +107,8 @@ describe('SolidUIHeaderElement', () => { expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg') loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true })) + await Promise.resolve() + await header.updateComplete expect(authActionSelected).toHaveBeenCalledWith({ role: 'login' @@ -105,6 +137,7 @@ describe('SolidUIHeaderElement', () => { it('uses a custom fallback avatar when no accountAvatar is configured', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' header.accountAvatar = '' @@ -123,6 +156,7 @@ describe('SolidUIHeaderElement', () => { it('renders an accounts dropdown with avatar when logged in', async () => { const header = new Header() const accountMenuSelected = jest.fn() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' header.accountIcon = 'https://example.com/account-icon.svg' @@ -173,6 +207,7 @@ describe('SolidUIHeaderElement', () => { it('does not render the logout icon on mobile layout', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' header.logoutIcon = 'https://example.com/logout-icon.svg' @@ -196,6 +231,7 @@ describe('SolidUIHeaderElement', () => { it('does not render account webid on mobile layout', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' header.accountMenu = [ @@ -263,6 +299,7 @@ describe('SolidUIHeaderElement', () => { it('renders helpMenuList inside the help dropdown and dispatches events', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) const helpMenuClicked = jest.fn() @@ -304,4 +341,35 @@ describe('SolidUIHeaderElement', () => { window.open = originalWindowOpen }) + + it('derives auth state from session on connect', async () => { + const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) + + document.body.appendChild(header) + await header.updateComplete + await Promise.resolve() + await header.updateComplete + + expect(authn.checkUser).toHaveBeenCalled() + expect(header.authState).toBe('logged-in') + }) + + it('refreshes auth state when session events fire', async () => { + const header = new Header() + document.body.appendChild(header) + await header.updateComplete + + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) + ;(authSession.events as any).emit('login') + await Promise.resolve() + await header.updateComplete + expect(header.authState).toBe('logged-in') + + ;(authn.currentUser as jest.Mock).mockReturnValue(null) + ;(authSession.events as any).emit('logout') + await Promise.resolve() + await header.updateComplete + expect(header.authState).toBe('logged-out') + }) }) From ac0d2e67c3f8ca9e5caf7fbba86ce1ab6276e02d Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 12:02:08 +0200 Subject: [PATCH 3/6] fix(header): stabilize auth resolution and delegate server logout --- src/v2/components/layout/header/Header.ts | 105 ++++++++++++------ .../components/layout/header/header.test.ts | 22 ++++ 2 files changed, 93 insertions(+), 34 deletions(-) diff --git a/src/v2/components/layout/header/Header.ts b/src/v2/components/layout/header/Header.ts index 63051df27..0e5ae9683 100644 --- a/src/v2/components/layout/header/Header.ts +++ b/src/v2/components/layout/header/Header.ts @@ -1,6 +1,6 @@ import { LitElement, html, css } from 'lit' import { icons } from '../../../../iconBase' -import { authSession } from 'solid-logic' +import { authSession, authn, performServerSideLogout } from 'solid-logic' import '../../auth/loginButton/index' import '../../auth/signupButton/index' import { ifDefined } from 'lit/directives/if-defined.js' @@ -10,6 +10,36 @@ const DEFAULT_SOLID_ICON_URL = 'https://solidproject.org/assets/img/solid-emblem const DEFAULT_SIGNUP_URL = 'https://solidproject.org/get_a_pod' const DEFAULT_LOGGEDIN_MENU_BUTTON_AVATAR = icons.iconBase + 'emptyProfileAvatar.png' +async function clearPersistedAuthState (): Promise { + if (typeof window === 'undefined') { + return + } + + const explicitKeys = ['loginIssuer', 'preLoginRedirectHash'] + for (const key of explicitKeys) { + window.localStorage.removeItem(key) + window.sessionStorage.removeItem(key) + } + + if (typeof indexedDB === 'undefined') { + return + } + + const databases = ['soidc', 'solid-client-authn-store', 'solid-client-authn'] + for (const dbName of databases) { + await new Promise((resolve) => { + try { + const request = indexedDB.deleteDatabase(dbName) + request.onsuccess = () => resolve() + request.onerror = () => resolve() + request.onblocked = () => resolve() + } catch (_err) { + resolve() + } + }) + } +} + export type HeaderAuthState = 'logged-out' | 'logged-in' export type HeaderMenuItem = { @@ -47,7 +77,8 @@ export class Header extends LitElement { accountMenuOpen: { state: true }, helpMenuOpen: { state: true }, hasSlottedAccountMenu: { state: true }, - hasSlottedHelpMenu: { state: true } + hasSlottedHelpMenu: { state: true }, + authResolved: { state: true } } static styles = css` @@ -510,8 +541,11 @@ export class Header extends LitElement { declare helpMenuOpen: boolean declare hasSlottedAccountMenu: boolean declare hasSlottedHelpMenu: boolean + declare authResolved: boolean + private _refreshPromise: Promise | null = null + private readonly handleAuthSessionChange = () => { - this.refreshAuthStateFromSession() + void this.refreshAuthStateFromSession() } constructor () { @@ -537,6 +571,7 @@ export class Header extends LitElement { this.helpMenuOpen = false this.hasSlottedAccountMenu = false this.hasSlottedHelpMenu = false + this.authResolved = false } connectedCallback () { @@ -548,7 +583,7 @@ export class Header extends LitElement { authSession.events.on('logout', this.handleAuthSessionChange) authSession.events.on('sessionRestore', this.handleAuthSessionChange) } - this.refreshAuthStateFromSession() + void this.refreshAuthStateFromSession() } disconnectedCallback () { @@ -563,12 +598,28 @@ export class Header extends LitElement { } private async refreshAuthStateFromSession () { + if (!this._refreshPromise) { + this._refreshPromise = (async () => { + try { + await authn.checkUser() + // Some auth stacks resolve session state asynchronously after first check. + if (!authn.currentUser()) { + await authn.checkUser() + } + } catch (_err) { + // Keep rendering even if session refresh cannot complete. + } + })() + } + try { - await authn.checkUser() - } catch (_err) { - // Keep rendering even if session refresh cannot complete. + await this._refreshPromise + } finally { + this._refreshPromise = null } + this.authState = authn.currentUser() ? 'logged-in' : 'logged-out' + this.authResolved = true } private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) { @@ -707,7 +758,15 @@ export class Header extends LitElement { // logout errors are non-fatal — proceed to clear state } - await this.performServerLogout(issuer) + await clearPersistedAuthState() + + const redirectedToServerLogout = await performServerSideLogout({ + issuer, + postLogoutRedirectPath: '/' + }) + if (redirectedToServerLogout) { + return + } await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('logout-select', { @@ -717,32 +776,6 @@ export class Header extends LitElement { })) } - private async performServerLogout (issuer: string) { - // Best-effort server logout for cookie-backed sessions on NSS-like servers. - try { - if (issuer) { - const wellKnownUri = new URL(issuer) - wellKnownUri.pathname = '/.well-known/openid-configuration' - const wellKnownResult = await fetch(wellKnownUri.toString(), { credentials: 'include' }) - - if (wellKnownResult.status === 200) { - const openidConfiguration = await wellKnownResult.json() - if (openidConfiguration && openidConfiguration.end_session_endpoint) { - await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) - } - } - } - } catch (_err) { - // Continue with local logout state even if remote IdP logout is unavailable. - } - - try { - await fetch('/.well-known/solid/logout', { credentials: 'include' }) - } catch (_err) { - // Not all deployments expose NSS-compatible well-known logout. - } - } - private renderAccountMenuItem (item: HeaderAccountMenuItem) { const content = html` ${this.renderLoggedInAvatar(item.avatar, 'account-menu-avatar')} @@ -844,6 +877,10 @@ export class Header extends LitElement { } private renderUserArea () { + if (!this.authResolved) { + return html`
` + } + if (this.authState === 'logged-out') { return this.renderLoggedOutActions() } diff --git a/src/v2/components/layout/header/header.test.ts b/src/v2/components/layout/header/header.test.ts index 55c4ee282..b75b11e99 100644 --- a/src/v2/components/layout/header/header.test.ts +++ b/src/v2/components/layout/header/header.test.ts @@ -10,6 +10,7 @@ jest.mock('solid-logic', () => ({ checkUser: jest.fn(async () => null), currentUser: jest.fn(() => null) }, + performServerSideLogout: jest.fn(async () => false), authSession: { logout: jest.fn(async () => undefined), events: { @@ -355,6 +356,27 @@ describe('SolidUIHeaderElement', () => { expect(header.authState).toBe('logged-in') }) + it('retries session resolution once before settling logged-out state', async () => { + const header = new Header() + let callCount = 0 + ;(authn.currentUser as jest.Mock).mockImplementation(() => { + return callCount >= 2 ? { uri: 'https://alice.example/profile/card#me' } : null + }) + ;(authn.checkUser as jest.Mock).mockImplementation(async () => { + callCount += 1 + return callCount >= 2 ? { uri: 'https://alice.example/profile/card#me' } : null + }) + + document.body.appendChild(header) + await header.updateComplete + await Promise.resolve() + await header.updateComplete + + expect(authn.checkUser).toHaveBeenCalledTimes(2) + expect(header.authResolved).toBe(true) + expect(header.authState).toBe('logged-in') + }) + it('refreshes auth state when session events fire', async () => { const header = new Header() document.body.appendChild(header) From 69366caca958c26aa309a1e78ed384c28ad6ba33 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 14:43:30 +0200 Subject: [PATCH 4/6] from Copilot review: use the sibling checkout when present, otherwise fall back to node_modules so CI/standalone clones work. --- jest.config.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/jest.config.mjs b/jest.config.mjs index 50862a9e2..f7f7fac63 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,3 +1,14 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const localSolidLogicSrc = path.resolve(__dirname, '../solid-logic/src') +const solidLogicMapper = existsSync(localSolidLogicSrc) + ? localSolidLogicSrc + : '/node_modules/solid-logic/src' + export default { // verbose: true, // Uncomment for detailed test output collectCoverage: true, @@ -16,7 +27,7 @@ export default { setupFilesAfterEnv: ['./test/helpers/setup.ts'], moduleNameMapper: { '^.+\\.css$': '/__mocks__/styleMock.js', - '^solid-logic$': '/../solid-logic/src', + '^solid-logic$': solidLogicMapper, '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts' }, testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], From 712c3f8cb1dbb7c2aed4a90cd107ab81d203ba8a Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 15:06:51 +0200 Subject: [PATCH 5/6] lint errors --- src/v2/components/layout/header/Header.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/v2/components/layout/header/Header.ts b/src/v2/components/layout/header/Header.ts index 0e5ae9683..ac5fa7143 100644 --- a/src/v2/components/layout/header/Header.ts +++ b/src/v2/components/layout/header/Header.ts @@ -545,7 +545,9 @@ export class Header extends LitElement { private _refreshPromise: Promise | null = null private readonly handleAuthSessionChange = () => { - void this.refreshAuthStateFromSession() + this.refreshAuthStateFromSession().catch(() => { + // Keep auth event handling resilient on transient refresh failures. + }) } constructor () { @@ -583,7 +585,9 @@ export class Header extends LitElement { authSession.events.on('logout', this.handleAuthSessionChange) authSession.events.on('sessionRestore', this.handleAuthSessionChange) } - void this.refreshAuthStateFromSession() + this.refreshAuthStateFromSession().catch(() => { + // Keep initial header render resilient on transient refresh failures. + }) } disconnectedCallback () { From 5657bd4bf32bab301e32a9f2824729065d3d32f8 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 15:19:42 +0200 Subject: [PATCH 6/6] update header tests --- .../components/layout/header/header.test.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/v2/components/layout/header/header.test.ts b/src/v2/components/layout/header/header.test.ts index b75b11e99..3af816087 100644 --- a/src/v2/components/layout/header/header.test.ts +++ b/src/v2/components/layout/header/header.test.ts @@ -29,6 +29,12 @@ jest.mock('solid-logic', () => ({ })) describe('SolidUIHeaderElement', () => { + async function waitForAuthRefresh (header: Header): Promise { + await Promise.resolve() + await Promise.resolve() + await header.updateComplete + } + beforeEach(() => { document.body.innerHTML = '' jest.clearAllMocks() @@ -53,6 +59,7 @@ describe('SolidUIHeaderElement', () => { header.setAttribute('help-icon', 'https://example.com/help.png') header.setAttribute('brand-link', '/home') header.authState = 'logged-out' + header.authResolved = true header.helpMenuList = [{ label: 'Help', action: 'open-help' }] header.innerHTML = '' @@ -83,6 +90,7 @@ describe('SolidUIHeaderElement', () => { const authActionSelected = jest.fn() header.authState = 'logged-out' + header.authResolved = true header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' } header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' } header.loginIcon = 'https://example.com/login-icon-top.svg' @@ -108,8 +116,7 @@ describe('SolidUIHeaderElement', () => { expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg') loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true })) - await Promise.resolve() - await header.updateComplete + await waitForAuthRefresh(header) expect(authActionSelected).toHaveBeenCalledWith({ role: 'login' @@ -119,6 +126,7 @@ describe('SolidUIHeaderElement', () => { it('does not show login or signup icons on mobile layout', async () => { const header = new Header() header.authState = 'logged-out' + header.authResolved = true header.layout = 'mobile' header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' } header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' } @@ -141,6 +149,7 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' + header.authResolved = true header.accountAvatar = '' header.accountAvatarFallback = 'https://example.com/fallback-avatar.png' @@ -160,6 +169,7 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' + header.authResolved = true header.accountIcon = 'https://example.com/account-icon.svg' header.accountAvatar = 'https://example.com/avatar.png' header.logoutIcon = 'https://example.com/logout-icon.svg' @@ -211,6 +221,7 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' + header.authResolved = true header.logoutIcon = 'https://example.com/logout-icon.svg' header.logoutLabel = 'Log Out' @@ -235,6 +246,7 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' + header.authResolved = true header.accountMenu = [ { label: 'Personal Pod', webid: 'https://pod.example/profile/card#me', action: 'switch-personal' } ] @@ -305,6 +317,7 @@ describe('SolidUIHeaderElement', () => { const helpMenuClicked = jest.fn() header.authState = 'logged-in' + header.authResolved = true header.helpIcon = '' header.helpMenuList = [{ label: 'Docs', url: 'https://example.com/docs', target: '_blank' }] @@ -349,8 +362,7 @@ describe('SolidUIHeaderElement', () => { document.body.appendChild(header) await header.updateComplete - await Promise.resolve() - await header.updateComplete + await waitForAuthRefresh(header) expect(authn.checkUser).toHaveBeenCalled() expect(header.authState).toBe('logged-in') @@ -384,14 +396,12 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) ;(authSession.events as any).emit('login') - await Promise.resolve() - await header.updateComplete + await waitForAuthRefresh(header) expect(header.authState).toBe('logged-in') ;(authn.currentUser as jest.Mock).mockReturnValue(null) ;(authSession.events as any).emit('logout') - await Promise.resolve() - await header.updateComplete + await waitForAuthRefresh(header) expect(header.authState).toBe('logged-out') }) })