diff --git a/tests/comment-event-builder.test.mjs b/tests/comment-event-builder.test.mjs index cbe91300..436bbcf5 100644 --- a/tests/comment-event-builder.test.mjs +++ b/tests/comment-event-builder.test.mjs @@ -53,3 +53,31 @@ test("buildCommentEvent includes parent pointers for threaded replies", () => { const participantTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "p"); assert.deepEqual(participantTags, [["p", "parent-author"]]); }); + +test("buildCommentEvent normalizes optional relays and preserves additional tags", () => { + const event = buildCommentEvent({ + pubkey: "author", + created_at: 1700000300, + videoEventId: "event123", + videoEventRelay: "wss://comments.main", + videoDefinitionAddress: "30078:deadbeefcafebabe:clip-2", + videoDefinitionRelay: "wss://video.def", + parentCommentId: "root-comment", + parentCommentRelay: "wss://parent", + threadParticipantPubkey: "cafecafe", + threadParticipantRelay: "wss://profile", + additionalTags: [["client", "bitvid"], ["p", "cafecafe", "wss://override"]], + content: " Appreciated! \ud800", + }); + + assert.equal(event.kind, 1); + assert.deepStrictEqual(event.tags, [ + ["e", "event123", "wss://comments.main"], + ["a", "30078:deadbeefcafebabe:clip-2", "wss://video.def"], + ["e", "root-comment", "wss://parent"], + ["p", "cafecafe", "wss://profile"], + ["client", "bitvid"], + ["p", "cafecafe", "wss://override"], + ]); + assert.equal(event.content, " Appreciated! "); +}); diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index 3388de1d..aa1fb3e1 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -1,5 +1,4 @@ -// Run with: node tests/comment-thread-service.test.mjs - +import test from "node:test"; import assert from "node:assert/strict"; import CommentThreadService from "../js/services/commentThreadService.js"; @@ -13,14 +12,17 @@ function createBaseVideo() { }; } -function createComment({ id, pubkey, createdAt, parentId = null }) { - const baseTags = [ - ["e", "video123"], - ["a", "30078:authorpk:video-root"], - ]; - const tags = parentId - ? [...baseTags, ["e", parentId]] - : [...baseTags]; +function createComment({ + id, + pubkey, + createdAt, + parentId = null, + extraTags = [], +}) { + const tags = [["e", "video123"], ["a", "30078:authorpk:video-root"], ...extraTags]; + if (parentId) { + tags.push(["e", parentId]); + } return { id, kind: 1, @@ -33,213 +35,210 @@ function createComment({ id, pubkey, createdAt, parentId = null }) { const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); -async function testThreadFlow() { - const video = createBaseVideo(); - const initialEvents = [ - createComment({ id: "comment-1", pubkey: "pk1", createdAt: 100 }), - createComment({ id: "comment-2", pubkey: "pk2", createdAt: 90 }), - createComment({ - id: "reply-1", - pubkey: "pk3", - createdAt: 110, - parentId: "comment-1", - }), - ]; +test( + "CommentThreadService hydrates, subscribes, and dedupes incoming comment events", + async (t) => { + const video = createBaseVideo(); + const initialEvents = [ + createComment({ id: "comment-1", pubkey: "pk1", createdAt: 100 }), + createComment({ id: "comment-2", pubkey: "pk2", createdAt: 90 }), + createComment({ + id: "reply-1", + pubkey: "pk3", + createdAt: 110, + parentId: "comment-1", + }), + ]; - const profileCache = new Map(); - const batchCalls = []; - const app = { - getProfileCacheEntry: (pubkey) => - profileCache.has(pubkey) ? { profile: profileCache.get(pubkey) } : null, - batchFetchProfiles: async (pubkeys) => { - batchCalls.push([...pubkeys]); - pubkeys.forEach((pubkey) => { - profileCache.set(pubkey, { - name: `Profile ${pubkey}`, - picture: `https://example.com/${pubkey}.png`, + let fetchCalls = 0; + let lastFetchTarget = null; + let lastFetchOptions = null; + const fetchVideoComments = async (target, options) => { + fetchCalls += 1; + lastFetchTarget = target; + lastFetchOptions = options; + return initialEvents; + }; + + let subscribeCalls = 0; + let unsubscribeCalled = 0; + let subscriptionHandler = null; + let subscriptionOptions = null; + let subscriptionTarget = null; + const subscribeVideoComments = (target, options = {}) => { + subscribeCalls += 1; + subscriptionTarget = target; + subscriptionOptions = options; + subscriptionHandler = typeof options.onEvent === "function" ? options.onEvent : null; + return () => { + unsubscribeCalled += 1; + }; + }; + + const cachedProfiles = new Map([ + ["pk1", { name: "Cached profile" }], + ]); + const hydrationRequests = []; + + const service = new CommentThreadService({ + fetchVideoComments, + subscribeVideoComments, + getProfileCacheEntry: (pubkey) => + cachedProfiles.get(pubkey?.toLowerCase?.() || pubkey) || null, + batchFetchProfiles: async (pubkeys) => { + hydrationRequests.push([...pubkeys]); + pubkeys.forEach((pubkey) => { + cachedProfiles.set(pubkey.toLowerCase(), { + name: `Profile ${pubkey}`, + }); }); - }); - }, - }; + }, + hydrationDebounceMs: 0, + }); - let subscriptionHandler = null; - const nostrClient = { - fetchVideoComments: async () => initialEvents, - subscribeVideoComments: (_target, options = {}) => { - subscriptionHandler = typeof options.onEvent === "function" - ? options.onEvent - : null; - return () => { - subscriptionHandler = null; - }; - }, - }; + const initialPayloads = []; + const appendedPayloads = []; + const errorPayloads = []; - const received = { - initial: [], - appended: [], - errors: [], - }; + service.setCallbacks({ + onThreadReady: (payload) => initialPayloads.push(payload), + onCommentsAppended: (payload) => appendedPayloads.push(payload), + onError: (error) => errorPayloads.push(error), + }); - const service = new CommentThreadService({ - fetchVideoComments: (...args) => nostrClient.fetchVideoComments(...args), - subscribeVideoComments: (...args) => - nostrClient.subscribeVideoComments(...args), - getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey), - batchFetchProfiles: (pubkeys) => app.batchFetchProfiles(pubkeys), - hydrationDebounceMs: 0, - }); + const snapshot = await service.loadThread({ video, relays: ["wss://relay.example"] }); + await service.waitForProfileHydration(); - service.setCallbacks({ - onThreadReady: (payload) => received.initial.push(payload), - onCommentsAppended: (payload) => received.appended.push(payload), - onError: (error) => received.errors.push(error), - }); + assert.equal(fetchCalls, 1, "fetchVideoComments should be called once"); + assert.deepStrictEqual(lastFetchTarget, { + videoEventId: "video123", + videoDefinitionAddress: "30078:authorpk:video-root", + parentCommentId: "", + }); + assert.deepStrictEqual(lastFetchOptions, { + limit: service.defaultLimit, + relays: ["wss://relay.example"], + }); - await service.loadThread({ video }); - await tick(10); - await service.waitForProfileHydration(); + assert.equal(subscribeCalls, 1, "subscribeVideoComments should be invoked"); + assert.deepStrictEqual(subscriptionTarget, lastFetchTarget); + assert.ok(subscriptionOptions?.onEvent, "subscription handler should be provided"); - assert.equal(received.initial.length, 1, "initial callback should fire once"); - assert.equal(received.errors.length, 0, "no errors should be reported"); - assert.equal(batchCalls.length, 1, "profiles should be fetched once for unknown authors"); - assert.deepEqual( - new Set(batchCalls[0]), - new Set(["pk1", "pk2", "pk3"]), - "hydration should request each unique pubkey", - ); + assert.equal(errorPayloads.length, 0, "no errors should be emitted"); + assert.equal(initialPayloads.length, 1, "thread ready callback should fire once"); - const initialPayload = received.initial[0]; - assert.ok(initialPayload.childrenByParent instanceof Map); - assert.deepEqual( - initialPayload.topLevelIds, - ["comment-2", "comment-1"], - "top-level comments should be sorted by created_at", - ); - assert.deepEqual( - initialPayload.childrenByParent.get(null), - ["comment-2", "comment-1"], - "root mapping should expose top-level ids", - ); - assert.deepEqual( - initialPayload.childrenByParent.get("comment-1"), - ["reply-1"], - "parent mapping should include replies", - ); - assert.ok( - service.getProfile("pk3"), - "profiles should be cached for retrieval", - ); - assert.ok(subscriptionHandler, "subscription handler should be registered"); + const initialSnapshot = initialPayloads[0]; + assert.ok(initialSnapshot.childrenByParent instanceof Map); + assert.deepStrictEqual(initialSnapshot.topLevelIds, [ + "comment-2", + "comment-1", + ]); + assert.deepStrictEqual( + initialSnapshot.childrenByParent.get(null), + ["comment-2", "comment-1"], + "root mapping should list top-level comments", + ); + assert.deepStrictEqual( + initialSnapshot.childrenByParent.get("comment-1"), + ["reply-1"], + "child mapping should include replies", + ); - const newReply = createComment({ - id: "reply-2", - pubkey: "pk4", - createdAt: 120, - parentId: "comment-2", - }); + assert.ok(service.getProfile("pk1"), "cached profile should be readable"); + assert.ok(service.getProfile("pk2"), "hydrated profile should be cached"); + assert.ok(service.getProfile("pk3"), "reply author profile should be cached"); + assert.deepStrictEqual( + hydrationRequests, + [["pk2", "pk3"]], + "profile hydration should fetch unknown authors once", + ); - subscriptionHandler(newReply); - await tick(10); - await service.waitForProfileHydration(); - for (let attempt = 0; attempt < 5 && batchCalls.length < 2; attempt += 1) { - await tick(20); - } - assert.equal( - batchCalls.length, - 2, - "hydration should fetch new reply authors", - ); - assert.equal( - service.profileQueue.size, - 0, - "profile queue should be cleared after hydration", - ); + assert.equal( + snapshot.childrenByParent.get(null).length, + 2, + "loadThread should return populated snapshot", + ); - assert.equal( - received.appended.length, - 1, - "one append callback should fire for the new reply", - ); - const appendPayload = received.appended[0]; - assert.equal( - appendPayload.parentCommentId, - "comment-2", - "append payload should identify the parent comment", - ); - assert.deepEqual( - appendPayload.commentIds, - ["reply-2"], - "append payload should surface the new reply id", - ); - assert.ok( - service.getProfilesSnapshot().has("pk4"), - "new reply author should be hydrated and cached", - ); + const newReply = createComment({ + id: "reply-2", + pubkey: "pk4", + createdAt: 120, + parentId: "comment-2", + }); - // Duplicate events should be ignored. - subscriptionHandler(newReply); - await tick(); - assert.equal( - received.appended.length, - 1, - "duplicate events should not trigger additional append callbacks", - ); + subscriptionHandler?.(newReply); + for (let attempt = 0; attempt < 5 && hydrationRequests.length < 2; attempt += 1) { + await tick(10); + await service.waitForProfileHydration(); + } - service.teardown(); -} + assert.equal(appendedPayloads.length, 1, "new reply should trigger append callback"); + const appendPayload = appendedPayloads[0]; + assert.equal(appendPayload.parentCommentId, "comment-2"); + assert.deepStrictEqual(appendPayload.commentIds, ["reply-2"]); + assert.ok(appendPayload.commentsById instanceof Map); + assert.ok( + appendPayload.commentsById.has("reply-2"), + "appended payload should contain the new reply", + ); + assert.equal(hydrationRequests.length, 2, "hydration should run for appended replies"); + assert.deepStrictEqual(hydrationRequests[1], ["pk4"]); + const profilesSnapshot = service.getProfilesSnapshot(); + assert.equal( + profilesSnapshot.has("pk4"), + true, + "profile snapshot should include the new reply author", + ); + assert.ok(service.getProfile("pk4"), "new reply author should be hydrated"); + assert.equal(service.profileQueue.size, 0, "profile queue should be flushed"); -async function testTeardownCancelsHydration() { - const video = createBaseVideo(); - const delayedReply = createComment({ - id: "delayed-1", - pubkey: "pk9", - createdAt: 200, - }); + subscriptionHandler?.(newReply); + await tick(); + assert.equal( + appendedPayloads.length, + 1, + "duplicate events should not trigger extra append callbacks", + ); - let unsubscribed = false; - let batchCallCount = 0; - let subscriptionHandler = null; - const app = { - getProfileCacheEntry: () => null, - batchFetchProfiles: async () => { - batchCallCount += 1; - }, - }; + service.teardown(); + assert.equal(unsubscribeCalled, 1, "teardown should unsubscribe from feed"); + }, +); - const nostrClient = { - fetchVideoComments: async () => [], - subscribeVideoComments: (_target, options = {}) => { - subscriptionHandler = typeof options.onEvent === "function" - ? options.onEvent - : null; - return () => { - unsubscribed = true; - }; - }, - }; +test( + "CommentThreadService teardown cancels hydration timers", + async () => { + const video = createBaseVideo(); + const delayedComment = createComment({ id: "pending-1", pubkey: "pk9", createdAt: 200 }); - const service = new CommentThreadService({ - fetchVideoComments: (...args) => nostrClient.fetchVideoComments(...args), - subscribeVideoComments: (...args) => - nostrClient.subscribeVideoComments(...args), - getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey), - batchFetchProfiles: (pubkeys) => app.batchFetchProfiles(pubkeys), - hydrationDebounceMs: 50, - }); + let unsubscribeCalled = false; + let hydrationCalls = 0; + let subscriptionHandler = null; - await service.loadThread({ video }); - assert.ok(subscriptionHandler, "subscription handler should be available"); + const service = new CommentThreadService({ + fetchVideoComments: async () => [], + subscribeVideoComments: (_target, options = {}) => { + subscriptionHandler = typeof options.onEvent === "function" ? options.onEvent : null; + return () => { + unsubscribeCalled = true; + }; + }, + getProfileCacheEntry: () => null, + batchFetchProfiles: async () => { + hydrationCalls += 1; + }, + hydrationDebounceMs: 50, + }); - subscriptionHandler(delayedReply); - service.teardown(); - await tick(80); + await service.loadThread({ video }); + assert.ok(subscriptionHandler, "subscription handler should be registered"); - assert.equal(batchCallCount, 0, "teardown should cancel pending hydration timers"); - assert.equal(unsubscribed, true, "teardown should unsubscribe from nostr feed"); -} + subscriptionHandler(delayedComment); + service.teardown(); -await testThreadFlow(); -await testTeardownCancelsHydration(); + await tick(80); -console.log("comment-thread-service tests passed"); + assert.equal(hydrationCalls, 0, "hydration timer should be cancelled on teardown"); + assert.equal(unsubscribeCalled, true, "teardown should invoke subscription cleanup"); + }, +); diff --git a/tests/video-modal-accessibility.test.mjs b/tests/video-modal-accessibility.test.mjs index 74d96b15..aaa0cd31 100644 --- a/tests/video-modal-accessibility.test.mjs +++ b/tests/video-modal-accessibility.test.mjs @@ -894,6 +894,68 @@ for (const _ of [0]) { } ); + test( + "video modal comments region exposes aria landmarks and stays in the focus order", + async (t) => { + const { document, modal, playerModal, trigger, cleanup } = await setupModal(); + t.after(cleanup); + + modal.open(null, { triggerElement: trigger }); + await Promise.resolve(); + + const commentsRoot = document.querySelector("[data-comments-root]"); + const commentsHeading = document.getElementById("videoModalCommentsHeading"); + const commentComposer = document.querySelector("[data-comments-composer]"); + const commentInput = document.querySelector("[data-comments-input]"); + + assert.ok(commentsRoot, "expected comment container"); + assert.ok(commentsHeading, "expected comment heading"); + assert.ok(commentComposer, "expected comment composer"); + assert.ok(commentInput, "expected comment textarea"); + + assert.equal(commentsRoot.getAttribute("role"), "region"); + assert.equal( + commentsRoot.getAttribute("aria-labelledby"), + commentsHeading.id, + ); + assert.equal( + commentComposer.getAttribute("aria-labelledby"), + commentsHeading.id, + ); + assert.equal( + commentInput.getAttribute("aria-describedby"), + "commentComposerHelp commentComposerCount", + ); + + const focusableSelectors = [ + "a[href]", + "area[href]", + "button:not([disabled])", + 'input:not([type="hidden"]):not([disabled])', + "select:not([disabled])", + "textarea:not([disabled])", + "iframe", + "object", + "embed", + '[contenteditable="true"]', + "[tabindex]", + "audio[controls]", + "video[controls]", + ].join(","); + + const focusable = Array.from( + playerModal.querySelectorAll(focusableSelectors), + ); + + assert.ok( + focusable.includes(commentInput), + "comment textarea should participate in modal focus order", + ); + + modal.close(); + }, + ); + test( "video modal zap dialog updates aria state while toggling", async (t) => { diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs new file mode 100644 index 00000000..1e9e1ee6 --- /dev/null +++ b/tests/video-modal-comments.test.mjs @@ -0,0 +1,229 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { setupModal } from "./video-modal-accessibility.test.mjs"; + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "area[href]", + "button:not([disabled])", + 'input:not([type="hidden"]):not([disabled])', + "select:not([disabled])", + "textarea:not([disabled])", + "iframe", + "object", + "embed", + '[contenteditable="true"]', + "[tabindex]", + "audio[controls]", + "video[controls]", +].join(","); + +function createCommentEvent({ id, pubkey, content, createdAt, parentId = null }) { + const tags = [["e", "video123"], ["a", "30078:authorpk:video-root"]]; + if (parentId) { + tags.push(["e", parentId]); + } + return { + id, + pubkey, + content, + kind: 1, + created_at: createdAt, + tags, + }; +} + +test("VideoModal comment section toggles visibility and renders hydrated comments", async (t) => { + const { document, modal, cleanup } = await setupModal(); + t.after(cleanup); + + const commentsRoot = document.querySelector("[data-comments-root]"); + assert.ok(commentsRoot, "comment root should exist"); + + modal.setCommentsVisibility(false); + assert.equal(commentsRoot.hasAttribute("hidden"), true); + assert.equal(commentsRoot.classList.contains("hidden"), true); + + modal.setCommentsVisibility(true); + assert.equal(commentsRoot.hasAttribute("hidden"), false); + assert.equal(commentsRoot.classList.contains("hidden"), false); + + const snapshot = { + videoEventId: "video123", + parentCommentId: null, + commentsById: new Map([ + [ + "comment-1", + createCommentEvent({ + id: "comment-1", + pubkey: "pk1", + content: "First!", + createdAt: 1700000000, + }), + ], + [ + "reply-1", + createCommentEvent({ + id: "reply-1", + pubkey: "pk2", + content: "Thanks for sharing", + createdAt: 1700000005, + parentId: "comment-1", + }), + ], + ]), + childrenByParent: new Map([ + [null, ["comment-1"]], + ["comment-1", ["reply-1"]], + ]), + profiles: new Map([ + ["pk1", { name: "Alice" }], + ["pk2", { name: "Bob" }], + ]), + }; + + modal.renderComments(snapshot); + + const renderedItems = Array.from( + modal.commentsList.querySelectorAll("[data-comment-id]"), + ); + assert.equal(renderedItems.length, 2, "should render top-level comment and reply"); + + const topLevel = modal.commentsList.querySelector('[data-comment-id="comment-1"]'); + assert.ok(topLevel, "top-level comment node should render"); + assert.equal(topLevel.dataset.commentDepth, "0"); + + const reply = modal.commentsList.querySelector('[data-comment-id="reply-1"]'); + assert.ok(reply, "reply node should render"); + assert.equal(reply.dataset.commentDepth, "1"); + assert.equal(reply.closest("ol").getAttribute("role"), "list"); + + const countLabel = document.querySelector("[data-comments-count]"); + assert.equal(countLabel.textContent.trim(), "2 comments"); + + const emptyState = document.querySelector("[data-comments-empty]"); + assert.equal(emptyState.hasAttribute("hidden"), true); + assert.equal(modal.commentsList.getAttribute("data-empty"), null); +}); + +test("VideoModal comment composer updates messaging and dispatches events", async (t) => { + const { window, document, modal, cleanup } = await setupModal(); + t.after(cleanup); + + const defaultHint = modal.commentComposerDefaultHint; + assert.ok(defaultHint); + + modal.setCommentComposerState({ disabled: true, reason: "login-required" }); + assert.equal(modal.commentsInput.disabled, true); + assert.equal(modal.commentsComposer.hasAttribute("hidden"), false); + assert.equal(modal.commentsDisabledMessage.hasAttribute("hidden"), true); + assert.equal(modal.commentComposerHint.textContent.trim(), "Log in to add a comment."); + assert.equal(modal.commentsStatusMessage.textContent.trim(), ""); + + modal.setCommentComposerState({ disabled: false, reason: "error" }); + assert.equal(modal.commentsInput.disabled, false); + assert.equal( + modal.commentsStatusMessage.textContent.includes( + "We couldn't post your comment. Try again?", + ), + true, + ); + assert.ok(modal.commentRetryButton, "retry button should be rendered for error state"); + + modal.setCommentComposerState({ disabled: true, reason: "disabled" }); + assert.equal(modal.commentsComposer.hasAttribute("hidden"), true); + assert.equal(modal.commentsDisabledMessage.hasAttribute("hidden"), false); + + modal.setCommentComposerState({ disabled: false, reason: "" }); + assert.equal(modal.commentsComposer.hasAttribute("hidden"), false); + assert.equal(modal.commentsDisabledMessage.hasAttribute("hidden"), true); + assert.equal(modal.commentComposerHint.textContent.trim(), defaultHint.trim()); + + const loginRequests = []; + const submitEvents = []; + modal.addEventListener("comment:login-required", (event) => { + loginRequests.push(event.detail); + }); + modal.addEventListener("comment:submit", (event) => { + submitEvents.push(event.detail); + }); + + modal.setCommentComposerState({ disabled: true, reason: "login-required" }); + modal.commentsComposer.dispatchEvent( + new window.Event("submit", { bubbles: true, cancelable: true }), + ); + assert.equal(loginRequests.length, 1); + assert.equal(submitEvents.length, 0); + + modal.setCommentComposerState({ disabled: false, reason: "" }); + modal.commentsInput.value = " New comment "; + modal.updateCommentCharCount(); + modal.updateCommentSubmitState(); + assert.equal(modal.commentsSubmitButton.disabled, false); + + modal.commentsComposer.dispatchEvent( + new window.Event("submit", { bubbles: true, cancelable: true }), + ); + + assert.equal(submitEvents.length, 1); + const submitDetail = submitEvents[0]; + assert.deepStrictEqual(submitDetail.parentId, null); + assert.equal(submitDetail.text, "New comment"); + assert.strictEqual(submitDetail.triggerElement, modal.commentsSubmitButton); +}); + +test("VideoModal comment section exposes aria landmarks and participates in focus trap", async (t) => { + const { window, document, modal, playerModal, trigger, cleanup } = await setupModal(); + t.after(cleanup); + + modal.open(null, { triggerElement: trigger }); + await Promise.resolve(); + + const commentsRoot = document.querySelector("[data-comments-root]"); + const commentsHeading = document.getElementById("videoModalCommentsHeading"); + assert.ok(commentsRoot, "comment root should exist when modal opens"); + assert.ok(commentsHeading, "comment heading should exist"); + assert.equal(commentsRoot.getAttribute("role"), "region"); + assert.equal(commentsRoot.getAttribute("aria-labelledby"), commentsHeading.id); + assert.equal(modal.commentsComposer.getAttribute("aria-labelledby"), commentsHeading.id); + + const commentInput = document.querySelector("[data-comments-input]"); + assert.ok(commentInput, "comment textarea should exist"); + assert.equal( + commentInput.getAttribute("aria-describedby"), + "commentComposerHelp commentComposerCount", + ); + + const focusableElements = Array.from( + playerModal.querySelectorAll(FOCUSABLE_SELECTOR), + ); + focusableElements.forEach((element) => { + if (element === commentInput) { + element.removeAttribute("tabindex"); + element.disabled = false; + return; + } + if (typeof element.disabled === "boolean") { + element.disabled = true; + } + element.setAttribute("tabindex", "-1"); + }); + + commentInput.focus(); + const tabEvent = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + commentInput.dispatchEvent(tabEvent); + + assert.equal(tabEvent.defaultPrevented, true, "focus trap should intercept Tab"); + const activeAfter = document.activeElement; + assert.ok( + activeAfter === commentInput || activeAfter === modal.modalPanel, + "focus trap should keep focus within the modal", + ); + + modal.close(); +});