0

I'm working on my portfolio site, and learning how to write TypeScript and test scripts. I'm stuck resolving these errors:

RUN  v3.2.4 /Users/sigma/Documents/Websites/Site/Site 5

 ❯ tests/lightbox.test.ts (3 tests | 3 failed) 3087ms
   × lightbox dialog > opens and focuses the Close button 1050ms
     → expected false to be true // Object.is equality
                                                                                                                                                                                                                                                                                                                 
   × lightbox dialog > updates caption and description from dataset 1016ms
     → expected false to be true // Object.is equality
                                                                                                                                                                                                                                                                                                                      
   × lightbox dialog > prev/next disabled at the ends 1020ms
     → expected false to be true // Object.is equality
   
 FAIL  tests/lightbox.test.ts > lightbox dialog > opens and focuses the Close button
AssertionError: expected false to be true // Object.is equality

 ❯ tests/lightbox.test.ts:72:67
     70| 
     71|         const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
     72|         await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
       |                                                                   ^
     73|         const closeBtn = screen.getByRole('button', { name: /close/i });
     74|         expect(closeBtn).toHaveFocus();
 ❯ runWithExpensiveErrorDiagnosticsDisabled node_modules/@testing-library/dom/dist/config.js:47:12
 ❯ checkCallback node_modules/@testing-library/dom/dist/wait-for.js:124:77
 ❯ Timeout.checkRealTimersCallback node_modules/@testing-library/dom/dist/wait-for.js:118:16

 FAIL  tests/lightbox.test.ts > lightbox dialog > updates caption and description from dataset
AssertionError: expected false to be true // Object.is equality
   
 ❯ tests/lightbox.test.ts:87:67
     85| 
     86|         const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
     87|         await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
       |                                                                   ^
     88|         expect(screen.getByText('Two')).toBeInTheDocument();
     89|         expect(screen.getByText('Desc 2')).toBeInTheDocument();
 ❯ runWithExpensiveErrorDiagnosticsDisabled node_modules/@testing-library/dom/dist/config.js:47:12
 ❯ checkCallback node_modules/@testing-library/dom/dist/wait-for.js:124:77
 ❯ Timeout.checkRealTimersCallback node_modules/@testing-library/dom/dist/wait-for.js:118:16

 FAIL  tests/lightbox.test.ts > lightbox dialog > prev/next disabled at the ends
AssertionError: expected false to be true // Object.is equality


 ❯ tests/lightbox.test.ts:106:67
    104| 
    105|         const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
    106|         await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
       |                                                                   ^
    107| 
    108|         await waitFor(() => {
 ❯ runWithExpensiveErrorDiagnosticsDisabled node_modules/@testing-library/dom/dist/config.js:47:12
 ❯ checkCallback node_modules/@testing-library/dom/dist/wait-for.js:124:77
 ❯ Timeout.checkRealTimersCallback node_modules/@testing-library/dom/dist/wait-for.js:118:16

 Test Files  1 failed (1)
      Tests  3 failed (3)
   Start at  10:21:44
   Duration  3.77s (transform 25ms, setup 123ms, collect 39ms, tests 3.09s, environment 281ms, prepare 75ms)

So my dialogs work when I run the code in the browser. Just for reference here are the snippets of the code:

...
<li class="card">
      <h3>Import from LinkedIn</h3>
      <p>One-click import of saved jobs from LinkedIn to reduce manual data entry.</p>
      <hr />
      <figure class="shot">
        <a href="assets/img/full/image_full.png"
           data-lightbox
           data-full="assets/img/full/image_full.png"
           data-title="Wireframe: Importing saved jobs from LinkedIn"
           data-description="One-click import of saved jobs from LinkedIn to reduce manual data entry."
           data-lightbox-group="ga-wireframes"
           aria-label="View full-size wireframe: Importing saved jobs from LinkedIn">
          <img src="assets/img/thumb/image_thumb.png"
               alt="Wireframe: Importing saved jobs from LinkedIn"
               width="751" height="1158" loading="lazy" decoding="async">
        </a>
        <figcaption>Wireframe — Import from LinkedIn</figcaption>
      </figure>
    </li>

<!-- Lightbox dialog (shared, keep once per page) -->
<dialog class="lb-dialog" aria-modal="true" aria-label="Image Viewer" tabindex="-1">
    <div class="lb-header">
        <h2 class="lb-title" id="lb-title">Image Viewer</h2>
        <button class="btn lb-btn" id="lb-close-btn" aria-label="Close" type="button">
            <i class="fa-solid fa-xmark"></i>
            <span class="sr-only">Close</span>
        </button>
    </div>
    <div class="lb-nav">
        <button type="button" class="lb-prev lb-arrow" aria-label="Previous image" aria-disabled="true" disabled>
            <i class="fa-solid fa-chevron-left"></i>
        </button>
        <button type="button" class="lb-next lb-arrow" aria-label="Next image" aria-disabled="false">
            <i class="fa-solid fa-chevron-right"></i>
        </button>
    </div>
    <div class="lb-body">
        <img class="lb-media" alt="">
    </div>
    <div class="lb-bar">
        <p class="lb-caption" id="lb-caption"></p>
        <p class="lb-desc" id="lb-desc"></p>
        <div class="lb-actions">
            <button class="btn lb-btn" value="close" aria-label="Close" id="lb-bar-close-btn" type="button">
                <i class="fa-solid fa-xmark"></i> Close
            </button>
        </div>
    </div>
</dialog>

And the lightbox-dialog.ts file:

type Trigger = HTMLElement & {
    dataset: {
        lightbox?: string;
        full?: string;
        title?: string;
        lightboxGroup?: string;
        caption?: string;
        description?: string;
    };
};

export class DialogLightbox {
    private readonly dialog!: HTMLDialogElement;
    private readonly bodyEl!: HTMLDivElement;
    private readonly img!: HTMLImageElement;
    private readonly captionEl!: HTMLParagraphElement;
    private readonly descEl!: HTMLParagraphElement;
    private readonly titleEl!: HTMLElement;
    private readonly prevBtn!: HTMLButtonElement;
    private readonly nextBtn!: HTMLButtonElement;
    private readonly closeBtn!: HTMLButtonElement;

    public readonly triggers: Trigger[] = [];
    private group: Trigger[] = [];
    private index = 0;
    private lastActive: HTMLElement | null = null;

    constructor() {
        // For tests, select *all* elements with data-lightbox
        // This allows easy swap between <a> and <button> in test markup
        this.triggers = Array.from(document.querySelectorAll("[data-lightbox]")) as Trigger[];
        this.dialog = document.querySelector("dialog.lb-dialog") as HTMLDialogElement;
        if (!this.dialog) return;

        this.bodyEl = this.dialog.querySelector(".lb-body") as HTMLDivElement;
        this.img = this.dialog.querySelector(".lb-media") as HTMLImageElement;
        this.captionEl = this.dialog.querySelector(".lb-caption") as HTMLParagraphElement;
        this.descEl = this.dialog.querySelector('.lb-desc') as HTMLParagraphElement;
        this.titleEl = this.dialog.querySelector(".lb-title") as HTMLElement;
        this.prevBtn = this.dialog.querySelector(".lb-prev") as HTMLButtonElement;
        this.nextBtn = this.dialog.querySelector(".lb-next") as HTMLButtonElement;
        this.closeBtn = this.dialog.querySelector(".lb-header .lb-btn") as HTMLButtonElement
            || this.dialog.querySelector(".lb-bar .lb-btn") as HTMLButtonElement;

        this.prevBtn.type = "button";
        this.nextBtn.type = "button";
        if (this.closeBtn) this.closeBtn.type = "button";
        if (this.captionEl) this.captionEl.setAttribute("aria-live", "polite");
        if (this.descEl) this.descEl.setAttribute("aria-live", "polite");
        if (this.bodyEl) this.bodyEl.setAttribute("role", "document");
        if (this.img) {
            this.img.setAttribute("decoding", "async");
            this.img.setAttribute("loading", "eager");
            (this.img as any).fetchPriority = "high";
            this.img.addEventListener("load", () => {
                const nW = this.img.naturalWidth || 0;
                const nH = this.img.naturalHeight || 0;
                this.img.style.setProperty("--media-natural-w", String(nW));
                this.img.style.setProperty("--media-natural-h", String(nH));
                if (nH > nW) {
                    this.img.classList.add("is-portrait");
                    this.img.classList.remove("is-landscape");
                } else {
                    this.img.classList.add("is-landscape");
                    this.img.classList.remove("is-portrait");
                }
                const anyBodyEl = this.bodyEl as any;
                if (anyBodyEl && typeof anyBodyEl.scrollTo === "function") {
                    anyBodyEl.scrollTo({ top: 0 });
                }
            });
        }
        this.bind();
        this.updateArrowStates();
    }

    private bind() {
        this.triggers.forEach(t => {
            t.addEventListener("click", ev => {
                // DEBUG: log for test
                console.log("[Lightbox TEST] click fired for", t.dataset.title || t.textContent);
                ev.preventDefault();
                this.openFromTrigger(t);
            });
        });

        this.prevBtn.addEventListener("click", () => { if (!this.prevBtn.disabled) this.prev(); });
        this.nextBtn.addEventListener("click", () => { if (!this.nextBtn.disabled) this.next(); });

        if (this.closeBtn) {
            this.closeBtn.addEventListener("click", e => {
                e.preventDefault();
                this.close();
            });
        }

        this.dialog.addEventListener("keydown", (e: KeyboardEvent) => {
            switch (e.key) {
                case "ArrowLeft":
                    if (!this.prevBtn.disabled) { e.preventDefault(); this.prev(); }
                    break;
                case "ArrowRight":
                    if (!this.nextBtn.disabled) { e.preventDefault(); this.next(); }
                    break;
                case "Home":
                    if (this.group.length > 1 && this.index > 0) {
                        e.preventDefault();
                        this.index = 0; this.render();
                    }
                    break;
                case "End":
                    if (this.group.length > 1 && this.index < this.group.length - 1) {
                        e.preventDefault();
                        this.index = this.group.length - 1; this.render();
                    }
                    break;
            }
        });
        this.dialog.addEventListener("cancel", e => {
            e.preventDefault();
            this.close();
        });
        this.dialog.addEventListener("click", (e: MouseEvent) => {
            const rect = this.dialog.getBoundingClientRect();
            const inside =
                e.clientX >= rect.left && e.clientX <= rect.right &&
                e.clientY >= rect.top && e.clientY <= rect.bottom;
            if (!inside) this.close();
        });
        this.dialog.addEventListener("close", () => {
            document.body.classList.remove("lb-no-scroll");
            setTimeout(() => {
                this.lastActive?.focus();
            }, 0);
        });
    }

    private setButtonDisabled(btn: HTMLButtonElement | null, disabled: boolean) {
        if (!btn) return;
        btn.disabled = disabled;
        btn.setAttribute("aria-disabled", disabled ? "true" : "false");
    }

    private updateArrowStates() {
        const isGallery = this.group.length > 1;
        const isFirst = this.index === 0;
        const isLast = this.index === this.group.length - 1;
        this.prevBtn.style.display = isGallery ? "" : "none";
        this.nextBtn.style.display = isGallery ? "" : "none";
        this.setButtonDisabled(this.prevBtn, !isGallery || isFirst);
        this.setButtonDisabled(this.nextBtn, !isGallery || isLast);
    }

    private openFromTrigger(t: Trigger) {
        const g = t.dataset.lightboxGroup?.trim();
        const all = Array.from(document.querySelectorAll("[data-lightbox]")) as Trigger[];
        this.group = g && g.length
            ? all.filter(x => (x.dataset.lightboxGroup || "").trim() === g)
            : all;
        this.index = Math.max(0, this.group.findIndex(x => x === t));
        this.open();
    }

    private open() {
        this.lastActive = (document.activeElement as HTMLElement) || null;
        this.dialog.setAttribute("open", "");
        try { this.render(); } catch {}
        document.body.classList.add("lb-no-scroll");
        const hasShowModal = typeof (this.dialog as any).showModal === "function";
        if (hasShowModal) {
            try { (this.dialog as any).showModal(); } catch {}
        }
        document.body.setAttribute("data-lb-opened", "true");
        if (this.closeBtn) this.closeBtn.focus();
        else this.dialog.focus();
    }

    private close() {
        if (typeof this.dialog.close === "function") {
            this.dialog.close();
        } else {
            this.dialog.removeAttribute("open");
            document.body.classList.remove("lb-no-scroll");
            setTimeout(() => {
                this.lastActive?.focus();
            }, 0);
        }
    }

    private prev() {
        if (this.group.length <= 1) return;
        if (this.index > 0) { this.index--; this.render(); }
    }

    private next() {
        if (this.group.length <= 1) return;
        if (this.index < this.group.length - 1) { this.index++; this.render(); }
    }

    private render() {
        const cur = this.group[this.index];
        const src = cur.dataset.full || cur.getAttribute("href") || "";
        const title = cur.dataset.title || cur.getAttribute("title") || "";
        const caption = cur.dataset.caption || title;
        const desc = cur.dataset.description || "";

        if (this.img.getAttribute("src") !== src) {
            this.img.removeAttribute("src");
            this.img.setAttribute("src", src);
        }
        this.titleEl.textContent = title || "Image Viewer";
        this.captionEl.textContent = caption;
        this.descEl.textContent = desc;
        this.img.alt = title || "Expanded image";
        this.dialog.setAttribute("aria-label", title ? `Image viewer — ${title}` : "Image viewer");
        this.updateArrowStates();
        (this.bodyEl as any)?.scrollTo?.({ top: 0 });
    }
}

export function initLightbox(): DialogLightbox | null {
    const dlg = document.querySelector("dialog.lb-dialog") as HTMLDialogElement | null;
    if (!dlg) return null;
    const instance = new DialogLightbox();
    try {
        (globalThis as any).__lb = instance;
        (globalThis as any).__lbOpen = (idx: number) => {
            const all = Array.from(document.querySelectorAll("[data-lightbox]")) as Trigger[];
            (instance as any).group = all;
            (instance as any).index = Math.max(0, Math.min(idx | 0, all.length - 1));
            (instance as any).open();
        };
    } catch {}
    return instance;
}

and the test script:

/// <reference types="vitest/globals" />
import { fireEvent, screen, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { initLightbox } from '../scripts/lightbox-dialog';

// Polyfill for <dialog> methods in jsdom tests
beforeAll(() => {
    const proto = HTMLDialogElement.prototype as any;
    if (typeof proto.showModal !== 'function') {
        proto.showModal = function showModal(this: HTMLDialogElement) {
            this.setAttribute('open', '');
            if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '-1');
            (this as unknown as HTMLElement).focus?.();
        };
    }
    if (typeof proto.close !== 'function') {
        proto.close = function close(this: HTMLDialogElement) {
            this.removeAttribute('open');
        };
    }
});

// IMPORTANT: Update test triggers so they have visible content!
function buildMarkup(): void {
    document.body.innerHTML = `
    <!-- Triggers (with visible text, like your real UI) -->
    <a data-lightbox data-title="One"  data-description="Desc 1" href="#one">One</a>
    <a data-lightbox data-title="Two"  data-description="Desc 2" href="#two">Two</a>
    <a data-lightbox data-title="Three" data-description="Desc 3" href="#three">Three</a>
    <div id="one" hidden></div>
    <div id="two" hidden></div>
    <div id="three" hidden></div>
    <dialog class="lb-dialog" aria-modal="true"
            aria-labelledby="lb-title"
            aria-describedby="lb-caption lb-desc">
      <div class="lb-header">
        <h2 class="lb-title" id="lb-title">Image viewer</h2>
        <button class="lb-btn">Close</button>
      </div>
      <div class="lb-body">
        <img class="lb-media" alt="Preview" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==">
      </div>
      <div class="lb-nav">
        <button class="lb-prev lb-arrow" aria-disabled="true"><i></i></button>
        <button class="lb-next lb-arrow"><i></i></button>
      </div>
      <div class="lb-bar">
        <p class="lb-caption" id="lb-caption"></p>
        <p class="lb-desc" id="lb-desc"></p>
        <div class="lb-actions"></div>
      </div>
    </dialog>
  `;
}

describe('lightbox dialog', () => {
    beforeEach(() => {
        buildMarkup();
        initLightbox();
    });

    it('opens and focuses the Close button', async () => {
        const __lbOpen = (globalThis as any).__lbOpen as undefined | ((i: number) => void);
        if (typeof __lbOpen === 'function') {
            __lbOpen(0);
        } else {
            const first = screen.getAllByRole('link')[0] as HTMLAnchorElement;
            await userEvent.click(first);
        }

        const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
        await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
        const closeBtn = screen.getByRole('button', { name: /close/i });
        expect(closeBtn).toHaveFocus();
    });

    it('updates caption and description from dataset', async () => {
        const __lbOpen = (globalThis as any).__lbOpen as undefined | ((i: number) => void);
        if (typeof __lbOpen === 'function') {
            __lbOpen(1);
        } else {
            const second = screen.getAllByRole('link')[1] as HTMLAnchorElement;
            await userEvent.click(second);
        }

        const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
        await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
        expect(screen.getByText('Two')).toBeInTheDocument();
        expect(screen.getByText('Desc 2')).toBeInTheDocument();
    });

    it('prev/next disabled at the ends', async () => {
        const links = screen.getAllByRole('link') as HTMLAnchorElement[];
        const firstLink = links[0];
        const thirdLink = links[2];

        const __lbOpen = (globalThis as any).__lbOpen as undefined | ((i: number) => void);

        if (typeof __lbOpen === 'function') {
            __lbOpen(0);
        } else {
            await userEvent.click(firstLink);
        }

        const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
        await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));

        await waitFor(() => {
            const prev = document.querySelector<HTMLButtonElement>('.lb-prev')!;
            const next = document.querySelector<HTMLButtonElement>('.lb-next')!;
            expect(prev).toBeDisabled();
            expect(next).not.toBeDisabled();
        });

        if (typeof __lbOpen === 'function') {
            __lbOpen(2);
        } else {
            await userEvent.click(thirdLink);
        }
        await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));

        await waitFor(() => {
            const prev = document.querySelector<HTMLButtonElement>('.lb-prev')!;
            const next = document.querySelector<HTMLButtonElement>('.lb-next')!;
            expect(prev).not.toBeDisabled();
            expect(next).toBeDisabled();
        });

        fireEvent.keyDown(dialogEl, { key: 'Home' });
        await waitFor(() => {
            const prev = document.querySelector<HTMLButtonElement>('.lb-prev')!;
            expect(prev).toBeDisabled();
        });

        fireEvent.keyDown(dialogEl, { key: 'End' });
        await waitFor(() => {
            const next = document.querySelector<HTMLButtonElement>('.lb-next')!;
            expect(next).toBeDisabled();
        });
    });
});

any help will be greatly appreciated.

1
  • Thanks for sharing code, but would it be possible to turn it into a minimal reproducible example? As written it seems like there are a lot of irrelevant details, as well as not enough information to easily run the tests ourselves. You could use something like stackblitz.com or codesandbox.io to create a runnable example. Commented Sep 24 at 12:58

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.