From f3ee2b21192303e89f0082c0aaa6fb07c3b7e8bd Mon Sep 17 00:00:00 2001 From: K8Sewell Date: Thu, 2 Jul 2026 15:58:11 -0500 Subject: [PATCH 1/9] Add AV tests and cookbook transcript display --- __tests__/av_tests.js | 478 ++++++++++++++++++ .../MediaElementCenterPanel.ts | 121 ++++- .../captioned-video-manifest.json | 48 ++ src/test-fixtures/captions.vtt | 9 + ...cross-origin-captioned-video-manifest.json | 48 ++ .../redirected-captioned-video-manifest.json | 48 ++ .../supplementing-annotation-page.json | 22 + ...ing-external-captioned-video-manifest.json | 47 ++ webpack.config.js | 4 + 9 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 __tests__/av_tests.js create mode 100644 src/test-fixtures/captioned-video-manifest.json create mode 100644 src/test-fixtures/captions.vtt create mode 100644 src/test-fixtures/cross-origin-captioned-video-manifest.json create mode 100644 src/test-fixtures/redirected-captioned-video-manifest.json create mode 100644 src/test-fixtures/supplementing-annotation-page.json create mode 100644 src/test-fixtures/supplementing-external-captioned-video-manifest.json diff --git a/__tests__/av_tests.js b/__tests__/av_tests.js new file mode 100644 index 000000000..6d849f3a0 --- /dev/null +++ b/__tests__/av_tests.js @@ -0,0 +1,478 @@ +const puppeteer = require("puppeteer"); +const { BASE_URL } = require("../scripts/testBaseUrl"); + +// AV (audiovisual) manifest for AV-specific behaviour. A simple single-file +// AV manifest (no ranges) is rendered by the mediaelement extension. +const AV_VIDEO_MANIFEST = + "https://iiif.io/api/cookbook/recipe/0003-mvm-video/manifest.json"; + +// AV manifest WITH a table of contents (structures/ranges). When an AV manifest +// has ranges and preferMediaElementExtension is false (the default), the viewer +// uses uv-av-extension -> AVCenterPanel (iiif-av-component) instead of the +// mediaelement player. +const AV_TOC_MANIFEST = + "https://iiif.io/api/cookbook/recipe/0064-opera-one-canvas/manifest.json"; + +// AV manifests with a transcription. UV surfaces transcriptions/captions in +// the mediaelement player when the canvas provides them as a text/vtt (or +// text/srt) rendering, as an additional body on the painting annotation, or +// as a supplementing annotation per IIIF cookbook recipe 0219 (inline or in +// an externally referenced annotation page). The AV extension used for +// manifests with ranges has no caption support. +// +// The player fetches the VTT with XHR, so cross-origin transcriptions work +// only when every response hop (including any redirect) carries an +// Access-Control-Allow-Origin header. The same-origin fixture below is +// immune to that; the cross-origin fixture points at fixtures.iiif.io, +// which serves CORS headers on a direct https URL. +const AV_CAPTIONED_MANIFEST = `${BASE_URL}/test-fixtures/captioned-video-manifest.json`; +const AV_CROSS_ORIGIN_CAPTIONED_MANIFEST = `${BASE_URL}/test-fixtures/cross-origin-captioned-video-manifest.json`; + +// This fixture references the VTT through http://dlib.indiana.edu, whose 301 +// upgrade redirect carries no CORS headers, so the URL is unreadable as-is. +// UV resolves such captions to their https destination before wiring the +// track (MediaElementCenterPanel.resolveCaptionSource). +const AV_REDIRECTED_CAPTIONED_MANIFEST = `${BASE_URL}/test-fixtures/redirected-captioned-video-manifest.json`; + +// The IIIF cookbook's own example of a captioned video: the VTT is a +// supplementing annotation in an inline annotation page on the canvas. +const AV_COOKBOOK_CAPTION_MANIFEST = + "https://iiif.io/api/cookbook/recipe/0219-using-caption-file/manifest.json"; + +// Same supplementing pattern, but the canvas references the annotation page +// by id only, so the viewer has to fetch it. +const AV_SUPPLEMENTING_EXTERNAL_MANIFEST = `${BASE_URL}/test-fixtures/supplementing-external-captioned-video-manifest.json`; + +const viewerUrl = (manifestUrl) => { + //const separator = BASE_URL.includes("#?") ? "&" : "#?"; + return `${BASE_URL}#?manifest=${encodeURIComponent(manifestUrl)}`; +}; + +describe("Universal Viewer", () => { + let browser; + let page; + + beforeAll(async () => { + browser = await puppeteer.launch({ + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + page = await browser.newPage(); + }); + + afterAll(async () => { + await browser.close(); + }); + + // AV MANIFEST TEST + describe("AV manifest", () => { + // Use a dedicated page so the AV tests are isolated from whatever state + // (e.g. in-flight iframe loads) earlier suites left on the shared page. + let avPage; + + beforeAll(async () => { + avPage = await browser.newPage(); + }); + + afterAll(async () => { + await avPage.close(); + }); + + beforeEach(async () => { + // The example page reads the manifest from the URL only on the initial + // document load, so force a full reload (a hash-only change would not + // re-initialise the viewer). + await avPage.goto("about:blank"); + await avPage.goto(viewerUrl(AV_VIDEO_MANIFEST), { + waitUntil: "domcontentloaded", + }); + }, 60000); + + it("loads the AV manifest into the mediaelement player", async () => { + expect(avPage.url()).toContain(encodeURIComponent(AV_VIDEO_MANIFEST)); + + await avPage.waitForSelector(".uv", { visible: true }); + + // The AV manifest is handled by the mediaelement extension, which adds + // this class to the extension host element. + await avPage.waitForSelector(".uv-mediaelement-extension", { + visible: true, + }); + + // The MediaElement.js player renders into a .mejs__container. For a + // video canvas it also carries the .mejs__video class. + await avPage.waitForSelector(".mejs__container.mejs__video", { + visible: true, + }); + + // The underlying media element points at the canvas' video resource. + const videoSrc = await avPage.$eval( + ".mejs__mediaelement video", + (el) => el.src || el.querySelector("source")?.src || "" + ); + expect(videoSrc).toMatch(/\.mp4($|\?)/); + + const pageText = await avPage.evaluate(() => document.body.innerText); + expect(pageText).not.toContain("Unable to load"); + expect(pageText).not.toContain("Error loading"); + }, 60000); + + it("renders AV playback controls", async () => { + await avPage.waitForSelector(".mejs__controls", { visible: true }); + + // Play / pause button. + const playButton = await avPage.$(".mejs__playpause-button"); + expect(playButton).toBeTruthy(); + + // Current time readout. + const currentTime = await avPage.$eval(".mejs__currenttime", (el) => + el.textContent.trim() + ); + expect(currentTime).toMatch(/^\d{2}:\d{2}/); + }, 60000); + }); + + // AV MANIFEST WITH TABLE OF CONTENTS TEST + // An AV manifest that defines ranges/structures is routed to the AV extension + // (AVCenterPanel / iiif-av-component) rather than the mediaelement player. + describe("AV manifest with table of contents", () => { + // Use a dedicated page so the AV tests are isolated from whatever state + // (e.g. in-flight iframe loads) earlier suites left on the shared page. + let avPage; + + beforeAll(async () => { + avPage = await browser.newPage(); + }); + + afterAll(async () => { + await avPage.close(); + }); + + beforeEach(async () => { + // Force a full reload so the viewer re-initialises on this manifest + // (a hash-only change would keep the previously loaded manifest). + await avPage.goto("about:blank"); + await avPage.goto(viewerUrl(AV_TOC_MANIFEST), { + waitUntil: "domcontentloaded", + }); + }, 60000); + + it("loads the AV manifest into the AV center panel", async () => { + expect(avPage.url()).toContain(encodeURIComponent(AV_TOC_MANIFEST)); + + await avPage.waitForSelector(".uv", { visible: true }); + + // The AV extension mounts the AVComponent into a .iiif-av-component + // wrapper inside the center panel. + await avPage.waitForSelector(".iiif-av-component .player", { + visible: true, + }); + + // This path must NOT fall back to the mediaelement player. + const mejsCount = await avPage.$$eval( + ".mejs__container", + (els) => els.length + ); + expect(mejsCount).toBe(0); + + // The media element is created as video.anno / audio.anno. + await avPage.waitForSelector( + ".iiif-av-component video.anno, .iiif-av-component audio.anno" + ); + + const pageText = await avPage.evaluate(() => document.body.innerText); + expect(pageText).not.toContain("Unable to load"); + expect(pageText).not.toContain("Error loading"); + }, 60000); + + it("renders AV component playback controls", async () => { + await avPage.waitForSelector(".iiif-av-component .controls-container", { + visible: true, + }); + + // Play/pause button. + const playButton = await avPage.$( + ".iiif-av-component .controls-container .av-icon-play" + ); + expect(playButton).toBeTruthy(); + + // Duration display. + const duration = await avPage.$( + ".iiif-av-component .time-display .canvas-duration" + ); + expect(duration).toBeTruthy(); + }, 60000); + }); + + // AV MANIFEST WITH A TRANSCRIPTION TEST + describe("AV manifest with a transcription", () => { + let avPage; + + beforeAll(async () => { + avPage = await browser.newPage(); + }); + + afterAll(async () => { + await avPage.close(); + }); + + beforeEach(async () => { + // Force a full reload so the viewer re-initialises on this manifest. + await avPage.goto("about:blank"); + await avPage.goto(viewerUrl(AV_CAPTIONED_MANIFEST), { + waitUntil: "domcontentloaded", + }); + }, 60000); + + it("surfaces the transcription as a caption track in the player", async () => { + await avPage.waitForSelector(".mejs__container.mejs__video", { + visible: true, + }); + + // The captions button only renders when a caption track was wired up. + await avPage.waitForSelector(".mejs__captions-button", { + visible: true, + }); + + // The points at the manifest's VTT transcription. + const track = await avPage.$eval("track[src*='captions.vtt']", (t) => ({ + kind: t.kind, + srclang: t.srclang, + label: t.label, + })); + expect(track.kind).toBe("subtitles"); + expect(track.srclang).toBe("en"); + expect(track.label).toBe("English captions"); + + // The transcription appears as a selectable option, and becomes + // enabled once the player has loaded the VTT file. + await avPage.waitForFunction(() => { + const input = document.querySelector( + ".mejs__captions-selector input:not([value='none'])" + ); + return input && !input.disabled; + }); + }, 60000); + + it("displays the transcription text during playback", async () => { + await avPage.waitForSelector(".mejs__captions-button", { + visible: true, + }); + + // Wait for the player to finish loading the VTT. + await avPage.waitForFunction(() => { + const input = document.querySelector( + ".mejs__captions-selector input:not([value='none'])" + ); + return input && !input.disabled; + }); + + // Turn captions on through the player UI. + await avPage.evaluate(() => { + document + .querySelector(".mejs__captions-selector input:not([value='none'])") + .click(); + }); + + // Play (muted, so headless autoplay is allowed) to reach the first cue. + await avPage.evaluate(() => { + const video = document.querySelector(".mejs__mediaelement video"); + video.muted = true; + return video.play(); + }); + + await avPage.waitForFunction(() => { + const el = document.querySelector(".mejs__captions-text"); + return el && el.textContent.trim().length > 0; + }); + + const captionText = await avPage.$eval(".mejs__captions-text", (el) => + el.textContent.trim() + ); + expect(captionText).toBe( + "Just before lunch one day, a puppet show was put on at school." + ); + }, 60000); + + it("displays a cross-origin transcription served with CORS headers", async () => { + await avPage.goto("about:blank"); + await avPage.goto(viewerUrl(AV_CROSS_ORIGIN_CAPTIONED_MANIFEST), { + waitUntil: "domcontentloaded", + }); + + await avPage.waitForSelector(".mejs__captions-button", { + visible: true, + }); + + // The points at the cross-origin VTT. + const trackSrc = await avPage.$eval( + "track[src*='lunchroom_manners.vtt']", + (t) => t.src + ); + expect(trackSrc).toBe( + "https://fixtures.iiif.io/video/indiana/lunchroom_manners/lunchroom_manners.vtt" + ); + + // The caption option only becomes enabled once the player has + // successfully fetched the cross-origin VTT. + await avPage.waitForFunction(() => { + const input = document.querySelector( + ".mejs__captions-selector input:not([value='none'])" + ); + return input && !input.disabled; + }); + + // Turn captions on and play; the first cue ("[music]") starts 1.2s in. + await avPage.evaluate(() => { + document + .querySelector(".mejs__captions-selector input:not([value='none'])") + .click(); + const video = document.querySelector(".mejs__mediaelement video"); + video.muted = true; + return video.play(); + }); + + await avPage.waitForFunction(() => { + const el = document.querySelector(".mejs__captions-text"); + return el && el.textContent.trim().length > 0; + }); + + const captionText = await avPage.$eval(".mejs__captions-text", (el) => + el.textContent.trim() + ); + expect(captionText).toBe("[music]"); + }, 60000); + + it("resolves a transcription behind an institutional redirect", async () => { + await avPage.goto("about:blank"); + await avPage.goto(viewerUrl(AV_REDIRECTED_CAPTIONED_MANIFEST), { + waitUntil: "domcontentloaded", + }); + + await avPage.waitForSelector(".mejs__captions-button", { + visible: true, + }); + + // The manifest's http:// URL is unreadable (its redirect hop has no + // CORS headers), so UV must have resolved the track to the readable + // https destination. + const trackSrc = await avPage.$eval( + "track[src*='lunchroom_manners.vtt']", + (t) => t.src + ); + expect(trackSrc).toBe( + "https://dlib.indiana.edu/iiif_av/lunchroom_manners/lunchroom_manners.vtt" + ); + + await avPage.waitForFunction(() => { + const input = document.querySelector( + ".mejs__captions-selector input:not([value='none'])" + ); + return input && !input.disabled; + }); + + await avPage.evaluate(() => { + document + .querySelector(".mejs__captions-selector input:not([value='none'])") + .click(); + const video = document.querySelector(".mejs__mediaelement video"); + video.muted = true; + return video.play(); + }); + + await avPage.waitForFunction(() => { + const el = document.querySelector(".mejs__captions-text"); + return el && el.textContent.trim().length > 0; + }); + + const captionText = await avPage.$eval(".mejs__captions-text", (el) => + el.textContent.trim() + ); + expect(captionText).toBe("[music]"); + }, 60000); + + it("displays a transcription supplied as a supplementing annotation (cookbook 0219)", async () => { + await avPage.goto("about:blank"); + await avPage.goto(viewerUrl(AV_COOKBOOK_CAPTION_MANIFEST), { + waitUntil: "domcontentloaded", + }); + + await avPage.waitForSelector(".mejs__captions-button", { + visible: true, + }); + + // The track carries the annotation body's label and language. + const track = await avPage.$eval( + "track[src*='lunchroom_manners.vtt']", + (t) => ({ label: t.label, srclang: t.srclang }) + ); + expect(track.label).toBe("Captions in WebVTT format"); + expect(track.srclang).toBe("en"); + + await avPage.waitForFunction(() => { + const input = document.querySelector( + ".mejs__captions-selector input:not([value='none'])" + ); + return input && !input.disabled; + }); + + await avPage.evaluate(() => { + document + .querySelector(".mejs__captions-selector input:not([value='none'])") + .click(); + const video = document.querySelector(".mejs__mediaelement video"); + video.muted = true; + return video.play(); + }); + + await avPage.waitForFunction(() => { + const el = document.querySelector(".mejs__captions-text"); + return el && el.textContent.trim().length > 0; + }); + + const captionText = await avPage.$eval(".mejs__captions-text", (el) => + el.textContent.trim() + ); + expect(captionText).toBe("[music]"); + }, 60000); + + it("displays a transcription from an externally referenced annotation page", async () => { + await avPage.goto("about:blank"); + await avPage.goto(viewerUrl(AV_SUPPLEMENTING_EXTERNAL_MANIFEST), { + waitUntil: "domcontentloaded", + }); + + await avPage.waitForSelector(".mejs__captions-button", { + visible: true, + }); + + await avPage.waitForFunction(() => { + const input = document.querySelector( + ".mejs__captions-selector input:not([value='none'])" + ); + return input && !input.disabled; + }); + + await avPage.evaluate(() => { + document + .querySelector(".mejs__captions-selector input:not([value='none'])") + .click(); + const video = document.querySelector(".mejs__mediaelement video"); + video.muted = true; + return video.play(); + }); + + await avPage.waitForFunction(() => { + const el = document.querySelector(".mejs__captions-text"); + return el && el.textContent.trim().length > 0; + }); + + const captionText = await avPage.$eval(".mejs__captions-text", (el) => + el.textContent.trim() + ); + expect(captionText).toBe( + "Just before lunch one day, a puppet show was put on at school." + ); + }, 60000); + }); +}); diff --git a/src/content-handlers/iiif/modules/uv-mediaelementcenterpanel-module/MediaElementCenterPanel.ts b/src/content-handlers/iiif/modules/uv-mediaelementcenterpanel-module/MediaElementCenterPanel.ts index 6cea0dfb3..154c587c3 100644 --- a/src/content-handlers/iiif/modules/uv-mediaelementcenterpanel-module/MediaElementCenterPanel.ts +++ b/src/content-handlers/iiif/modules/uv-mediaelementcenterpanel-module/MediaElementCenterPanel.ts @@ -27,6 +27,17 @@ type TextTrackDescriptor = { id: string; }; +const captionTypes = new Set(["text/vtt", "text/srt"]); + +// A label in raw annotation JSON may be a plain string or a language map. +const captionLabel = (label: any): string | undefined => { + if (!label || typeof label === "string") { + return label || undefined; + } + const values = label[Object.keys(label)[0]]; + return Array.isArray(values) ? values[0] : undefined; +}; + type MediaSourceDescriptor = { label: string; type: string; @@ -206,7 +217,23 @@ export class MediaElementCenterPanel extends CenterPanel< } } + // Captions may also be supplied as supplementing annotations on the + // canvas (IIIF cookbook recipe 0219). + const supplementing = await this.getSupplementingCaptions(canvas); + for (const caption of supplementing) { + if (!subtitles.some((subtitle) => subtitle.id === caption.id)) { + subtitles.push(caption); + } + } + if (subtitles.length > 0) { + // Resolve caption URLs to ones the player's XHR will be able to read. + for (const subtitle of subtitles) { + if (subtitle.id) { + subtitle.id = await this.resolveCaptionSource(subtitle.id); + } + } + // Show captions options popover for better interface feedback subtitles.unshift({ id: "none" }); } @@ -392,6 +419,96 @@ export class MediaElementCenterPanel extends CenterPanel< this.extensionHost.publish(Events.LOAD); } + // Captions/transcriptions supplied as supplementing annotations in the + // canvas' annotations pages (IIIF cookbook recipe 0219). Inline + // annotation pages are read directly; pages referenced by id alone are + // fetched. + async getSupplementingCaptions( + canvas: Canvas + ): Promise { + const captions: TextTrackDescriptor[] = []; + const pages: any[] = canvas.getProperty("annotations") || []; + + for (const page of pages) { + let items: any[] = page.items; + + if (!items && page.id) { + try { + const response = await fetch(page.id); + if (response.ok) { + items = (await response.json()).items; + } + } catch { + console.warn( + `Annotation page ${page.id} could not be read (CORS headers are required); any captions it contains will be unavailable.` + ); + } + } + + if (!items) { + continue; + } + + for (const annotation of items) { + const motivations = Array.isArray(annotation.motivation) + ? annotation.motivation + : [annotation.motivation]; + + if (!motivations.includes("supplementing")) { + continue; + } + + const bodies = Array.isArray(annotation.body) + ? annotation.body + : [annotation.body]; + + for (const body of bodies) { + if (body && body.id && captionTypes.has(body.format)) { + captions.push({ + id: body.id, + label: captionLabel(body.label), + language: body.language, + }); + } + } + } + } + + return captions; + } + + // Captions are fetched with XHR by the player, so a cross-origin URL is + // only readable when every response hop carries CORS headers. Follow any + // CORS-friendly redirect to its final URL, and retry plain-http sources + // over https — an http -> https upgrade redirect whose 301 response lacks + // CORS headers is blocked by the browser even though its destination is + // readable. + async resolveCaptionSource(src: string): Promise { + const attempts: string[] = [src]; + + if (src.startsWith("http://")) { + attempts.push(src.replace(/^http:\/\//, "https://")); + } + + for (const attempt of attempts) { + try { + const response = await fetch(attempt); + if (response.ok) { + return response.url; + } + } catch { + // expected when the attempt is blocked by CORS or mixed content; + // fall through to the next candidate + } + } + + console.warn( + `Captions at ${src} could not be read (CORS headers are required on every response, including redirects); the player will omit this track.` + ); + + return src; + } + appendTextTracks(subtitles: Array) { for (const subtitle of subtitles) { this.$media.append( @@ -429,7 +546,7 @@ export class MediaElementCenterPanel extends CenterPanel< return typeGroup === "audio" || typeGroup === "video"; } - // vtt, srt, csv + // vtt, srt isTypeCaption(element: Rendering | AnnotationBody) { const type: RenderingFormat | MediaType | null = element.getFormat(); @@ -437,8 +554,6 @@ export class MediaElementCenterPanel extends CenterPanel< return false; } - const captionTypes = new Set(["text/vtt", "text/srt"]); - return captionTypes.has(type.toString()); } diff --git a/src/test-fixtures/captioned-video-manifest.json b/src/test-fixtures/captioned-video-manifest.json new file mode 100644 index 000000000..97c8b62ec --- /dev/null +++ b/src/test-fixtures/captioned-video-manifest.json @@ -0,0 +1,48 @@ +{ + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "http://localhost:4444/test-fixtures/captioned-video-manifest.json", + "type": "Manifest", + "label": { + "en": ["Video with captions (e2e test fixture)"] + }, + "items": [ + { + "id": "http://localhost:4444/test-fixtures/captioned-video-manifest.json/canvas", + "type": "Canvas", + "height": 360, + "width": 480, + "duration": 572.034, + "items": [ + { + "id": "http://localhost:4444/test-fixtures/captioned-video-manifest.json/canvas/page", + "type": "AnnotationPage", + "items": [ + { + "id": "http://localhost:4444/test-fixtures/captioned-video-manifest.json/canvas/page/annotation", + "type": "Annotation", + "motivation": "painting", + "target": "http://localhost:4444/test-fixtures/captioned-video-manifest.json/canvas", + "body": [ + { + "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/high/lunchroom_manners_1024kb.mp4", + "type": "Video", + "height": 360, + "width": 480, + "duration": 572.034, + "format": "video/mp4" + }, + { + "id": "http://localhost:4444/test-fixtures/captions.vtt", + "type": "Text", + "format": "text/vtt", + "label": "English captions", + "language": "en" + } + ] + } + ] + } + ] + } + ] +} diff --git a/src/test-fixtures/captions.vtt b/src/test-fixtures/captions.vtt new file mode 100644 index 000000000..f0903de25 --- /dev/null +++ b/src/test-fixtures/captions.vtt @@ -0,0 +1,9 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:20.000 +Just before lunch one day, a puppet show was put on at school. + +2 +00:00:20.100 --> 00:00:40.000 +It was called "Mister Bungle Goes to Lunch". diff --git a/src/test-fixtures/cross-origin-captioned-video-manifest.json b/src/test-fixtures/cross-origin-captioned-video-manifest.json new file mode 100644 index 000000000..d9859b06e --- /dev/null +++ b/src/test-fixtures/cross-origin-captioned-video-manifest.json @@ -0,0 +1,48 @@ +{ + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json", + "type": "Manifest", + "label": { + "en": ["Video with cross-origin captions (e2e test fixture)"] + }, + "items": [ + { + "id": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json/canvas", + "type": "Canvas", + "height": 360, + "width": 480, + "duration": 572.034, + "items": [ + { + "id": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json/canvas/page", + "type": "AnnotationPage", + "items": [ + { + "id": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json/canvas/page/annotation", + "type": "Annotation", + "motivation": "painting", + "target": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json/canvas", + "body": [ + { + "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/high/lunchroom_manners_1024kb.mp4", + "type": "Video", + "height": 360, + "width": 480, + "duration": 572.034, + "format": "video/mp4" + }, + { + "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/lunchroom_manners.vtt", + "type": "Text", + "format": "text/vtt", + "label": "English captions", + "language": "en" + } + ] + } + ] + } + ] + } + ] +} diff --git a/src/test-fixtures/redirected-captioned-video-manifest.json b/src/test-fixtures/redirected-captioned-video-manifest.json new file mode 100644 index 000000000..eecad2c41 --- /dev/null +++ b/src/test-fixtures/redirected-captioned-video-manifest.json @@ -0,0 +1,48 @@ +{ + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json", + "type": "Manifest", + "label": { + "en": ["Video with captions behind a redirect (e2e test fixture)"] + }, + "items": [ + { + "id": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json/canvas", + "type": "Canvas", + "height": 360, + "width": 480, + "duration": 572.034, + "items": [ + { + "id": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json/canvas/page", + "type": "AnnotationPage", + "items": [ + { + "id": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json/canvas/page/annotation", + "type": "Annotation", + "motivation": "painting", + "target": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json/canvas", + "body": [ + { + "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/high/lunchroom_manners_1024kb.mp4", + "type": "Video", + "height": 360, + "width": 480, + "duration": 572.034, + "format": "video/mp4" + }, + { + "id": "http://dlib.indiana.edu/iiif_av/lunchroom_manners/lunchroom_manners.vtt", + "type": "Text", + "format": "text/vtt", + "label": "English captions", + "language": "en" + } + ] + } + ] + } + ] + } + ] +} diff --git a/src/test-fixtures/supplementing-annotation-page.json b/src/test-fixtures/supplementing-annotation-page.json new file mode 100644 index 000000000..7b6a7ad2d --- /dev/null +++ b/src/test-fixtures/supplementing-annotation-page.json @@ -0,0 +1,22 @@ +{ + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "http://localhost:4444/test-fixtures/supplementing-annotation-page.json", + "type": "AnnotationPage", + "items": [ + { + "id": "http://localhost:4444/test-fixtures/supplementing-annotation-page.json/annotation", + "type": "Annotation", + "motivation": "supplementing", + "body": { + "id": "http://localhost:4444/test-fixtures/captions.vtt", + "type": "Text", + "format": "text/vtt", + "label": { + "en": ["English captions"] + }, + "language": "en" + }, + "target": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas" + } + ] +} diff --git a/src/test-fixtures/supplementing-external-captioned-video-manifest.json b/src/test-fixtures/supplementing-external-captioned-video-manifest.json new file mode 100644 index 000000000..a4923a7b6 --- /dev/null +++ b/src/test-fixtures/supplementing-external-captioned-video-manifest.json @@ -0,0 +1,47 @@ +{ + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json", + "type": "Manifest", + "label": { + "en": [ + "Video with captions in an external annotation page (e2e test fixture)" + ] + }, + "items": [ + { + "id": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas", + "type": "Canvas", + "height": 360, + "width": 480, + "duration": 572.034, + "items": [ + { + "id": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas/page", + "type": "AnnotationPage", + "items": [ + { + "id": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas/page/annotation", + "type": "Annotation", + "motivation": "painting", + "target": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas", + "body": { + "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/high/lunchroom_manners_1024kb.mp4", + "type": "Video", + "height": 360, + "width": 480, + "duration": 572.034, + "format": "video/mp4" + } + } + ] + } + ], + "annotations": [ + { + "id": "http://localhost:4444/test-fixtures/supplementing-annotation-page.json", + "type": "AnnotationPage" + } + ] + } + ] +} diff --git a/webpack.config.js b/webpack.config.js index 19a6606d5..a20c78922 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -136,6 +136,10 @@ const config = [ { from: resolvePath("./node_modules/mediaelement/build/mejs-controls.svg"), to: resolvePath("./dist"), + }, + { + from: resolvePath("./src/test-fixtures"), + to: resolvePath("./dist/test-fixtures"), } ], }), From 9bfeb2978bbf2de35880a7b19bc3e63060e3eeae Mon Sep 17 00:00:00 2001 From: K8Sewell Date: Thu, 2 Jul 2026 16:01:53 -0500 Subject: [PATCH 2/9] Reorganize tests per media type --- __tests__/{test.js => image_tests.js} | 80 +-------------- __tests__/pdf_tests.js | 142 ++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 78 deletions(-) rename __tests__/{test.js => image_tests.js} (85%) create mode 100644 __tests__/pdf_tests.js diff --git a/__tests__/test.js b/__tests__/image_tests.js similarity index 85% rename from __tests__/test.js rename to __tests__/image_tests.js index 1f050fcb1..f9d23bf10 100644 --- a/__tests__/test.js +++ b/__tests__/image_tests.js @@ -1,5 +1,3 @@ -test.skip("Configuration options", () => {}); - const puppeteer = require("puppeteer"); const { BASE_URL } = require("../scripts/testBaseUrl"); @@ -7,10 +5,6 @@ const { BASE_URL } = require("../scripts/testBaseUrl"); const COOKBOOK_BOUND_MULTIVOLUME_MANIFEST = "https://iiif.io/api/cookbook/recipe/0031-bound-multivolume/manifest.json"; -// PDF manifest for PDF-specific behaviour -const PDF_MULTI_FILE_MANIFEST = - "https://digital.library.villanova.edu/Item/vudl:294631/Manifest"; - const viewerUrl = (manifestUrl) => { //const separator = BASE_URL.includes("#?") ? "&" : "#?"; return `${BASE_URL}#?manifest=${encodeURIComponent(manifestUrl)}`; @@ -211,9 +205,8 @@ describe("Universal Viewer", () => { // COOKBOOK MANIFEST TEST describe("viewer controls", () => { beforeEach(async () => { - await page.goto(viewerUrl(COOKBOOK_BOUND_MULTIVOLUME_MANIFEST), - { - waitUntil: "domcontentloaded" + await page.goto(viewerUrl(COOKBOOK_BOUND_MULTIVOLUME_MANIFEST), { + waitUntil: "domcontentloaded", }); }); @@ -414,73 +407,4 @@ describe("Universal Viewer", () => { await page.waitForSelector(moreInfoHeader, { hidden: true }); }); }); - - // PDF MANIFEST TEST - describe("PDF manifest", () => { - beforeEach(async () => { - await page.goto(viewerUrl(PDF_MULTI_FILE_MANIFEST), { - waitUntil: "domcontentloaded", - }); - }); - - it("loads PDF manifest successfully", async () => { - expect(page.url()).toContain(encodeURIComponent(PDF_MULTI_FILE_MANIFEST)); - - await page.waitForSelector(".uv", { visible: true }); - - await page.waitForFunction(() => { - return document.querySelectorAll("iframe").length > 0; - }); - - const viewerFrame = page.frames().find((f) => { - const url = f.url(); - - return ( - url.includes("uv.html") || - url.includes("viewer") || - url.includes("manifest") - ); - }); - - expect(viewerFrame).toBeTruthy(); - - await viewerFrame.waitForSelector("canvas", { visible: true}); - - const canvasInfo = await viewerFrame.evaluate(() => { - const canvas = document.querySelector( - "canvas" - ); - - if (!canvas) return null; - return { - width: canvas.width, - height: canvas.height, - }; - }); - expect(canvasInfo).not.toBeNull(); - expect(canvasInfo.width).toBeGreaterThan(0); - expect(canvasInfo.height).toBeGreaterThan(0); - - const pageText = await viewerFrame.evaluate(() => document.body.innerText); - - expect(pageText).not.toContain("Unable to load"); - expect(pageText).not.toContain("Error loading"); - }); - - it("shows multiple PDF files in the sidebar and allows navigation", async () => { - await page.waitForSelector("button.expandButton", { visible: true }); - await page.click("button.expandButton"); - - await page.waitForSelector(".thumb", { visible: true }); - - const thumbs = await page.$$(".thumb"); - - expect(thumbs.length).toBeGreaterThan(1); - await thumbs[1].click(); - - await page.waitForFunction(() => window.location.href.includes("cv=1")); - - expect(page.url()).toContain("cv=1"); - }); - }); }); diff --git a/__tests__/pdf_tests.js b/__tests__/pdf_tests.js new file mode 100644 index 000000000..e18b9cf66 --- /dev/null +++ b/__tests__/pdf_tests.js @@ -0,0 +1,142 @@ +const puppeteer = require("puppeteer"); +const { BASE_URL } = require("../scripts/testBaseUrl"); + +// PDF manifest for PDF-specific behaviour +const PDF_MULTI_FILE_MANIFEST = + "https://digital.library.villanova.edu/Item/vudl:294631/Manifest"; + +const viewerUrl = (manifestUrl) => { + //const separator = BASE_URL.includes("#?") ? "&" : "#?"; + return `${BASE_URL}#?manifest=${encodeURIComponent(manifestUrl)}`; +}; + +describe("Universal Viewer", () => { + let browser; + let page; + + beforeAll(async () => { + browser = await puppeteer.launch({ + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + page = await browser.newPage(); + }); + + afterAll(async () => { + await browser.close(); + }); + + // PDF MANIFEST TEST + describe("PDF manifest", () => { + beforeEach(async () => { + await page.goto(viewerUrl(PDF_MULTI_FILE_MANIFEST), { + waitUntil: "domcontentloaded", + }); + }); + + it("loads PDF manifest successfully", async () => { + expect(page.url()).toContain(encodeURIComponent(PDF_MULTI_FILE_MANIFEST)); + + await page.waitForSelector(".uv", { visible: true }); + + await page.waitForFunction(() => { + return document.querySelectorAll("iframe").length > 0; + }); + + const viewerFrame = page.frames().find((f) => { + const url = f.url(); + + return ( + url.includes("uv.html") || + url.includes("viewer") || + url.includes("manifest") + ); + }); + + expect(viewerFrame).toBeTruthy(); + + await viewerFrame.waitForSelector("canvas", { visible: true }); + + const canvasInfo = await viewerFrame.evaluate(() => { + const canvas = document.querySelector("canvas"); + + if (!canvas) return null; + return { + width: canvas.width, + height: canvas.height, + }; + }); + expect(canvasInfo).not.toBeNull(); + expect(canvasInfo.width).toBeGreaterThan(0); + expect(canvasInfo.height).toBeGreaterThan(0); + + const pageText = await viewerFrame.evaluate( + () => document.body.innerText + ); + + expect(pageText).not.toContain("Unable to load"); + expect(pageText).not.toContain("Error loading"); + }); + + it("shows multiple PDF files in the sidebar and allows navigation", async () => { + // In a fresh browser session the left panel opens automatically + // (panelOpen defaults to true), so wait for it rather than clicking + // the expand button, which is only visible while the panel is closed. + await page.waitForSelector(".leftPanel.open", { visible: true }); + + await page.waitForSelector(".thumb", { visible: true }); + + const thumbs = await page.$$(".thumb"); + + expect(thumbs.length).toBeGreaterThan(1); + await thumbs[1].click(); + + await page.waitForFunction(() => window.location.href.includes("cv=1")); + + expect(page.url()).toContain("cv=1"); + }); + + it("can collapse and re-expand the sidebar with the expand button", async () => { + // The sidebar opens automatically, so collapse it first to make the + // expand button available. The open-finished class is toggled once + // the panel animation completes. + await page.waitForSelector(".leftPanel.open-finished", { + visible: true, + }); + await page.click(".leftPanel button.collapseButton"); + + // Collapsed: the panel content hides and the expand button appears. + await page.waitForFunction(() => { + const panel = document.querySelector(".leftPanel"); + return panel && !panel.classList.contains("open-finished"); + }); + await page.waitForSelector(".leftPanel button.expandButton", { + visible: true, + }); + await page.waitForSelector(".leftPanel .tabs", { hidden: true }); + + expect( + await page.$eval(".leftPanel button.expandButton", (btn) => + btn.getAttribute("aria-expanded") + ) + ).toBe("false"); + + await page.click(".leftPanel button.expandButton"); + + // Expanded again: the panel reopens and the thumbnails are visible. + await page.waitForSelector(".leftPanel.open-finished", { + visible: true, + }); + await page.waitForSelector(".thumb", { visible: true }); + + expect( + await page.$eval(".leftPanel", (el) => el.classList.contains("open")) + ).toBe(true); + expect( + await page.$eval(".leftPanel button.expandButton", (btn) => + btn.getAttribute("aria-expanded") + ) + ).toBe("true"); + }); + }); +}); From 8eb6d31efabd16bfe82edc5cacf7e31fa54d7068 Mon Sep 17 00:00:00 2001 From: K8Sewell Date: Thu, 2 Jul 2026 16:07:09 -0500 Subject: [PATCH 3/9] Re-enables test and configuration --- __tests__/configuration_options.js | 50 ------------- __tests__/configuration_tests.js | 73 +++++++++++++++++++ .../extensions/config/ContentLeftPanel.ts | 2 +- .../ContentLeftPanel.ts | 1 + .../uv-contentleftpanel-module/ThumbsView.tsx | 19 ++++- 5 files changed, 92 insertions(+), 53 deletions(-) delete mode 100644 __tests__/configuration_options.js create mode 100644 __tests__/configuration_tests.js diff --git a/__tests__/configuration_options.js b/__tests__/configuration_options.js deleted file mode 100644 index 07a3425b9..000000000 --- a/__tests__/configuration_options.js +++ /dev/null @@ -1,50 +0,0 @@ -const { BASE_URL } = require("../scripts/testBaseUrl"); - -describe("Configuration options", () => { - describe("thumb cache invalidation", () => { - beforeEach(async () => { - await page.goto(BASE_URL); - await page.waitForSelector("#thumb0"); - }); - it.skip("when set to false does not provide timestamp", async () => { - await page.evaluate(() => - uv.set({ - config: { - modules: { - contentLeftPanel: { - options: { thumbsCacheInvalidation: { enabled: false } }, - }, - }, - }, - }) - ); - await page.waitForSelector("#thumb0"); - const imageSrc = await page.$eval("#thumb0 img", (e) => e.src); - expect(imageSrc).toEqual( - expect.stringMatching( - "https://dlcs.io/iiif-img/wellcome/1/ff2085d5-a9c7-412e-9dbe-dda87712228d/full/90,/0/default.jpg" - ) - ); - }); - it.skip("has a configurable parameter type", async () => { - await page.evaluate(() => - uv.set({ - config: { - modules: { - contentLeftPanel: { - options: { thumbsCacheInvalidation: { paramType: "#" } }, - }, - }, - }, - }) - ); - await page.waitForSelector("#thumb0"); - const imageSrc = await page.$eval("#thumb0 img", (e) => e.src); - expect(imageSrc).toEqual( - expect.stringContaining( - "https://dlcs.io/iiif-img/wellcome/1/ff2085d5-a9c7-412e-9dbe-dda87712228d/full/90,/0/default.jpg#t=" - ) - ); - }); - }); -}); diff --git a/__tests__/configuration_tests.js b/__tests__/configuration_tests.js new file mode 100644 index 000000000..30d268cdf --- /dev/null +++ b/__tests__/configuration_tests.js @@ -0,0 +1,73 @@ +const { BASE_URL } = require("../scripts/testBaseUrl"); + +const FIRST_THUMB_SRC = + "https://iiif.wellcomecollection.org/image/b18035723_0001.JP2/full/90,/0/default.jpg"; + +// Applies custom config through the examples page's Configuration tab: +// the #customConfig JSON is merged into the viewer config by a +// uv.on("configure") handler when the Apply Configurations button +// re-initialises the viewer. +const applyCustomConfig = async (config) => { + await page.evaluate((cfg) => { + document.getElementById("customConfig").value = cfg; + document.getElementById("clearStorageCheckbox").checked = true; + document.getElementById("setConfigButton").click(); + }, JSON.stringify(config)); +}; + +describe("Configuration options", () => { + describe("thumb cache invalidation", () => { + beforeEach(async () => { + await page.goto(BASE_URL); + await page.waitForSelector("#thumb-0 img"); + }); + + it("when set to false does not provide timestamp", async () => { + await applyCustomConfig({ + modules: { + contentLeftPanel: { + options: { thumbsCacheInvalidation: { enabled: false } }, + }, + }, + }); + + // wait for the viewer to re-render thumbs without the timestamp + await page.waitForFunction( + (src) => { + const img = document.querySelector("#thumb-0 img"); + return img && img.src === src; + }, + {}, + FIRST_THUMB_SRC + ); + + const imageSrc = await page.$eval("#thumb-0 img", (e) => e.src); + expect(imageSrc).toEqual(FIRST_THUMB_SRC); + }); + + it("has a configurable parameter type", async () => { + await applyCustomConfig({ + modules: { + contentLeftPanel: { + options: { thumbsCacheInvalidation: { paramType: "#" } }, + }, + }, + }); + + // wait for the viewer to re-render thumbs with a #t= timestamp + await page.waitForFunction( + (src) => { + const img = document.querySelector("#thumb-0 img"); + return img && img.src.startsWith(`${src}#t=`); + }, + {}, + FIRST_THUMB_SRC + ); + + const imageSrc = await page.$eval("#thumb-0 img", (e) => e.src); + expect(imageSrc).toEqual( + expect.stringContaining(`${FIRST_THUMB_SRC}#t=`) + ); + }); + }); +}); diff --git a/src/content-handlers/iiif/extensions/config/ContentLeftPanel.ts b/src/content-handlers/iiif/extensions/config/ContentLeftPanel.ts index a7f2b46ae..d91e3ce28 100644 --- a/src/content-handlers/iiif/extensions/config/ContentLeftPanel.ts +++ b/src/content-handlers/iiif/extensions/config/ContentLeftPanel.ts @@ -1,7 +1,7 @@ import { ModuleConfig } from "../../BaseConfig"; import { ExpandPanelContent, ExpandPanelOptions } from "./ExpandPanel"; -type ThumbsCacheInvalidation = { +export type ThumbsCacheInvalidation = { /** Determines if cache invalidation is enabled */ enabled: boolean; /** Type of the parameter for cache invalidation */ diff --git a/src/content-handlers/iiif/modules/uv-contentleftpanel-module/ContentLeftPanel.ts b/src/content-handlers/iiif/modules/uv-contentleftpanel-module/ContentLeftPanel.ts index ab58980f0..fef15cb61 100644 --- a/src/content-handlers/iiif/modules/uv-contentleftpanel-module/ContentLeftPanel.ts +++ b/src/content-handlers/iiif/modules/uv-contentleftpanel-module/ContentLeftPanel.ts @@ -520,6 +520,7 @@ export class ContentLeftPanel extends LeftPanel { this.thumbsRoot.render( createElement(ThumbsView, { + cacheInvalidation: this.config.options.thumbsCacheInvalidation, thumbs, paged, viewingDirection: viewingDirection || ViewingDirection.LEFT_TO_RIGHT, diff --git a/src/content-handlers/iiif/modules/uv-contentleftpanel-module/ThumbsView.tsx b/src/content-handlers/iiif/modules/uv-contentleftpanel-module/ThumbsView.tsx index e97f5a535..cb94aa46a 100644 --- a/src/content-handlers/iiif/modules/uv-contentleftpanel-module/ThumbsView.tsx +++ b/src/content-handlers/iiif/modules/uv-contentleftpanel-module/ThumbsView.tsx @@ -1,10 +1,13 @@ import { ViewingDirection, ViewingHint } from "@iiif/vocabulary"; import cx from "classnames"; import { Thumb } from "manifesto.js"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useMemo, useRef } from "react"; import { useInView } from "react-intersection-observer"; +import { ThumbsCacheInvalidation } from "../../extensions/config/ContentLeftPanel"; +import { Dates } from "../../Utils"; const ThumbImage = ({ + cacheInvalidation, first, onClick, onKeyDown, @@ -14,6 +17,7 @@ const ThumbImage = ({ truncateThumbnailLabels, viewingDirection, }: { + cacheInvalidation?: ThumbsCacheInvalidation; first: boolean; onClick: (thumb: Thumb) => void; onKeyDown: (thumb: Thumb) => void; @@ -29,6 +33,14 @@ const ThumbImage = ({ triggerOnce: true, }); + // memoised so re-renders don't generate a new timestamp and re-request the image + const src = useMemo(() => { + if (thumb.uri && cacheInvalidation && cacheInvalidation.enabled) { + return `${thumb.uri}${cacheInvalidation.paramType}t=${Dates.getTimeStamp()}`; + } + return thumb.uri; + }, [thumb.uri, cacheInvalidation]); + var keydownHandler = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -62,7 +74,7 @@ const ThumbImage = ({ height: thumb.height + 8 + "px", }} > - {inView && {thumb.label}} + {inView && {thumb.label}}
@@ -77,6 +89,7 @@ const ThumbImage = ({ }; const Thumbnails = ({ + cacheInvalidation, onClick, onKeyDown, paged, @@ -86,6 +99,7 @@ const Thumbnails = ({ viewingDirection, truncateThumbnailLabels, }: { + cacheInvalidation?: ThumbsCacheInvalidation; onClick: (thumb: Thumb) => void; onKeyDown: (thumb: Thumb) => void; paged: boolean; @@ -149,6 +163,7 @@ const Thumbnails = ({ className="thumb-container" > Date: Thu, 2 Jul 2026 16:43:17 -0500 Subject: [PATCH 4/9] Remove file unrelevant to this work --- webpack.config.js | 158 ---------------------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 webpack.config.js diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100644 index a20c78922..000000000 --- a/webpack.config.js +++ /dev/null @@ -1,158 +0,0 @@ -const webpack = require("webpack"); -const pkg = require("./package.json"); -const CopyPlugin = require("copy-webpack-plugin"); -const path = require("path"); - -function resolvePath(p) { - return path.resolve(__dirname, p); -} - -const config = [ - { - entry: { - UV: ["./src/index.ts"], - }, - mode: "production", - output: { - path: resolvePath("dist/umd"), - publicPath: "auto", - libraryTarget: "umd", - library: "UV", - umdNamedDefine: true, - chunkFilename: "[name].[contenthash].js", - }, - resolve: { - extensions: [".ts", ".tsx", ".js"], - fallback: { - zlib: false, - stream: false, - }, - }, - module: { - rules: [ - { - test: /\.ts$/, - use: [{ loader: "ts-loader" }], - }, - { - test: /\.tsx$/, - use: [{ loader: "ts-loader" }], - }, - { - test: /\.css$/i, - use: ["style-loader", "css-loader"], - }, - { - test: /\.less$/, - use: [ - { - loader: "style-loader", - }, - { - loader: "css-loader", - options: { - sourceMap: true, - }, - }, - { - loader: "less-loader", - options: { - lessOptions: { - strictMath: true, - }, - }, - }, - ], - }, - { - test: /\.(png|jpg|gif|svg)$/i, - use: [ - { - loader: "url-loader", - options: { - limit: 8192, - }, - }, - ], - }, - ], - }, - plugins: [ - new webpack.EnvironmentPlugin({ - PACKAGE_VERSION: pkg.version, - }), - new webpack.ProvidePlugin({ - $: "jquery", - jQuery: "jquery", - "window.jQuery": "jquery", - }), - new CopyPlugin({ - patterns: [ - { - from: resolvePath("./src/index.html"), - to: resolvePath("./dist"), - transform(content) { - return Promise.resolve( - Buffer.from( - content - .toString() - .replace( - "<%= htmlWebpackPlugin.tags.headTags %>", - '' - ), - "utf8" - ) - ); - }, - }, - { - from: resolvePath("./src/iiif-collection.json"), - to: resolvePath("./dist"), - }, - { - from: resolvePath("./src/youtube-collection.json"), - to: resolvePath("./dist"), - }, - { - from: resolvePath("./src/favicon.ico"), - to: resolvePath("./dist"), - }, - { - from: resolvePath("./src/uv-iiif-config.json"), - to: resolvePath("./dist"), - }, - { - from: resolvePath("./src/uv-youtube-config.json"), - to: resolvePath("./dist"), - }, - { - from: resolvePath("./src/uv.css"), - to: resolvePath("./dist"), - }, - { - from: resolvePath("./src/uv.html"), - to: resolvePath("./dist"), - }, - { - from: resolvePath("./node_modules/mediaelement/build/mejs-controls.svg"), - to: resolvePath("./dist"), - }, - { - from: resolvePath("./src/test-fixtures"), - to: resolvePath("./dist/test-fixtures"), - } - ], - }), - ], - }, -]; - -if (process.env.NODE_WEBPACK_LIBRARY_PATH) { - config.output.path = resolvePath(process.env.NODE_WEBPACK_LIBRARY_PATH); -} - -if (process.env.NODE_WEBPACK_LIBRARY_TARGET) { - config.output.libraryTarget = process.env.NODE_WEBPACK_LIBRARY_TARGET; -} - -module.exports = config; From ef7ab9b2102e21c79fabaf658dd3394522bee67c Mon Sep 17 00:00:00 2001 From: K8Sewell Date: Thu, 2 Jul 2026 16:45:33 -0500 Subject: [PATCH 5/9] Revert file unrelevant to this work --- webpack.config.js | 154 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 webpack.config.js diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 000000000..19a6606d5 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,154 @@ +const webpack = require("webpack"); +const pkg = require("./package.json"); +const CopyPlugin = require("copy-webpack-plugin"); +const path = require("path"); + +function resolvePath(p) { + return path.resolve(__dirname, p); +} + +const config = [ + { + entry: { + UV: ["./src/index.ts"], + }, + mode: "production", + output: { + path: resolvePath("dist/umd"), + publicPath: "auto", + libraryTarget: "umd", + library: "UV", + umdNamedDefine: true, + chunkFilename: "[name].[contenthash].js", + }, + resolve: { + extensions: [".ts", ".tsx", ".js"], + fallback: { + zlib: false, + stream: false, + }, + }, + module: { + rules: [ + { + test: /\.ts$/, + use: [{ loader: "ts-loader" }], + }, + { + test: /\.tsx$/, + use: [{ loader: "ts-loader" }], + }, + { + test: /\.css$/i, + use: ["style-loader", "css-loader"], + }, + { + test: /\.less$/, + use: [ + { + loader: "style-loader", + }, + { + loader: "css-loader", + options: { + sourceMap: true, + }, + }, + { + loader: "less-loader", + options: { + lessOptions: { + strictMath: true, + }, + }, + }, + ], + }, + { + test: /\.(png|jpg|gif|svg)$/i, + use: [ + { + loader: "url-loader", + options: { + limit: 8192, + }, + }, + ], + }, + ], + }, + plugins: [ + new webpack.EnvironmentPlugin({ + PACKAGE_VERSION: pkg.version, + }), + new webpack.ProvidePlugin({ + $: "jquery", + jQuery: "jquery", + "window.jQuery": "jquery", + }), + new CopyPlugin({ + patterns: [ + { + from: resolvePath("./src/index.html"), + to: resolvePath("./dist"), + transform(content) { + return Promise.resolve( + Buffer.from( + content + .toString() + .replace( + "<%= htmlWebpackPlugin.tags.headTags %>", + '' + ), + "utf8" + ) + ); + }, + }, + { + from: resolvePath("./src/iiif-collection.json"), + to: resolvePath("./dist"), + }, + { + from: resolvePath("./src/youtube-collection.json"), + to: resolvePath("./dist"), + }, + { + from: resolvePath("./src/favicon.ico"), + to: resolvePath("./dist"), + }, + { + from: resolvePath("./src/uv-iiif-config.json"), + to: resolvePath("./dist"), + }, + { + from: resolvePath("./src/uv-youtube-config.json"), + to: resolvePath("./dist"), + }, + { + from: resolvePath("./src/uv.css"), + to: resolvePath("./dist"), + }, + { + from: resolvePath("./src/uv.html"), + to: resolvePath("./dist"), + }, + { + from: resolvePath("./node_modules/mediaelement/build/mejs-controls.svg"), + to: resolvePath("./dist"), + } + ], + }), + ], + }, +]; + +if (process.env.NODE_WEBPACK_LIBRARY_PATH) { + config.output.path = resolvePath(process.env.NODE_WEBPACK_LIBRARY_PATH); +} + +if (process.env.NODE_WEBPACK_LIBRARY_TARGET) { + config.output.libraryTarget = process.env.NODE_WEBPACK_LIBRARY_TARGET; +} + +module.exports = config; From 7bdf189e950da59b45dd828e0f691b7f496923e5 Mon Sep 17 00:00:00 2001 From: K8Sewell Date: Thu, 2 Jul 2026 16:47:24 -0500 Subject: [PATCH 6/9] Remove files unrelevant to this work --- __tests__/av_tests.js | 478 ------------------ __tests__/image_tests.js | 410 --------------- __tests__/pdf_tests.js | 142 ------ .../captioned-video-manifest.json | 48 -- src/test-fixtures/captions.vtt | 9 - ...cross-origin-captioned-video-manifest.json | 48 -- .../redirected-captioned-video-manifest.json | 48 -- .../supplementing-annotation-page.json | 22 - ...ing-external-captioned-video-manifest.json | 47 -- 9 files changed, 1252 deletions(-) delete mode 100644 __tests__/av_tests.js delete mode 100644 __tests__/image_tests.js delete mode 100644 __tests__/pdf_tests.js delete mode 100644 src/test-fixtures/captioned-video-manifest.json delete mode 100644 src/test-fixtures/captions.vtt delete mode 100644 src/test-fixtures/cross-origin-captioned-video-manifest.json delete mode 100644 src/test-fixtures/redirected-captioned-video-manifest.json delete mode 100644 src/test-fixtures/supplementing-annotation-page.json delete mode 100644 src/test-fixtures/supplementing-external-captioned-video-manifest.json diff --git a/__tests__/av_tests.js b/__tests__/av_tests.js deleted file mode 100644 index 6d849f3a0..000000000 --- a/__tests__/av_tests.js +++ /dev/null @@ -1,478 +0,0 @@ -const puppeteer = require("puppeteer"); -const { BASE_URL } = require("../scripts/testBaseUrl"); - -// AV (audiovisual) manifest for AV-specific behaviour. A simple single-file -// AV manifest (no ranges) is rendered by the mediaelement extension. -const AV_VIDEO_MANIFEST = - "https://iiif.io/api/cookbook/recipe/0003-mvm-video/manifest.json"; - -// AV manifest WITH a table of contents (structures/ranges). When an AV manifest -// has ranges and preferMediaElementExtension is false (the default), the viewer -// uses uv-av-extension -> AVCenterPanel (iiif-av-component) instead of the -// mediaelement player. -const AV_TOC_MANIFEST = - "https://iiif.io/api/cookbook/recipe/0064-opera-one-canvas/manifest.json"; - -// AV manifests with a transcription. UV surfaces transcriptions/captions in -// the mediaelement player when the canvas provides them as a text/vtt (or -// text/srt) rendering, as an additional body on the painting annotation, or -// as a supplementing annotation per IIIF cookbook recipe 0219 (inline or in -// an externally referenced annotation page). The AV extension used for -// manifests with ranges has no caption support. -// -// The player fetches the VTT with XHR, so cross-origin transcriptions work -// only when every response hop (including any redirect) carries an -// Access-Control-Allow-Origin header. The same-origin fixture below is -// immune to that; the cross-origin fixture points at fixtures.iiif.io, -// which serves CORS headers on a direct https URL. -const AV_CAPTIONED_MANIFEST = `${BASE_URL}/test-fixtures/captioned-video-manifest.json`; -const AV_CROSS_ORIGIN_CAPTIONED_MANIFEST = `${BASE_URL}/test-fixtures/cross-origin-captioned-video-manifest.json`; - -// This fixture references the VTT through http://dlib.indiana.edu, whose 301 -// upgrade redirect carries no CORS headers, so the URL is unreadable as-is. -// UV resolves such captions to their https destination before wiring the -// track (MediaElementCenterPanel.resolveCaptionSource). -const AV_REDIRECTED_CAPTIONED_MANIFEST = `${BASE_URL}/test-fixtures/redirected-captioned-video-manifest.json`; - -// The IIIF cookbook's own example of a captioned video: the VTT is a -// supplementing annotation in an inline annotation page on the canvas. -const AV_COOKBOOK_CAPTION_MANIFEST = - "https://iiif.io/api/cookbook/recipe/0219-using-caption-file/manifest.json"; - -// Same supplementing pattern, but the canvas references the annotation page -// by id only, so the viewer has to fetch it. -const AV_SUPPLEMENTING_EXTERNAL_MANIFEST = `${BASE_URL}/test-fixtures/supplementing-external-captioned-video-manifest.json`; - -const viewerUrl = (manifestUrl) => { - //const separator = BASE_URL.includes("#?") ? "&" : "#?"; - return `${BASE_URL}#?manifest=${encodeURIComponent(manifestUrl)}`; -}; - -describe("Universal Viewer", () => { - let browser; - let page; - - beforeAll(async () => { - browser = await puppeteer.launch({ - headless: true, - args: ["--no-sandbox", "--disable-setuid-sandbox"], - }); - page = await browser.newPage(); - }); - - afterAll(async () => { - await browser.close(); - }); - - // AV MANIFEST TEST - describe("AV manifest", () => { - // Use a dedicated page so the AV tests are isolated from whatever state - // (e.g. in-flight iframe loads) earlier suites left on the shared page. - let avPage; - - beforeAll(async () => { - avPage = await browser.newPage(); - }); - - afterAll(async () => { - await avPage.close(); - }); - - beforeEach(async () => { - // The example page reads the manifest from the URL only on the initial - // document load, so force a full reload (a hash-only change would not - // re-initialise the viewer). - await avPage.goto("about:blank"); - await avPage.goto(viewerUrl(AV_VIDEO_MANIFEST), { - waitUntil: "domcontentloaded", - }); - }, 60000); - - it("loads the AV manifest into the mediaelement player", async () => { - expect(avPage.url()).toContain(encodeURIComponent(AV_VIDEO_MANIFEST)); - - await avPage.waitForSelector(".uv", { visible: true }); - - // The AV manifest is handled by the mediaelement extension, which adds - // this class to the extension host element. - await avPage.waitForSelector(".uv-mediaelement-extension", { - visible: true, - }); - - // The MediaElement.js player renders into a .mejs__container. For a - // video canvas it also carries the .mejs__video class. - await avPage.waitForSelector(".mejs__container.mejs__video", { - visible: true, - }); - - // The underlying media element points at the canvas' video resource. - const videoSrc = await avPage.$eval( - ".mejs__mediaelement video", - (el) => el.src || el.querySelector("source")?.src || "" - ); - expect(videoSrc).toMatch(/\.mp4($|\?)/); - - const pageText = await avPage.evaluate(() => document.body.innerText); - expect(pageText).not.toContain("Unable to load"); - expect(pageText).not.toContain("Error loading"); - }, 60000); - - it("renders AV playback controls", async () => { - await avPage.waitForSelector(".mejs__controls", { visible: true }); - - // Play / pause button. - const playButton = await avPage.$(".mejs__playpause-button"); - expect(playButton).toBeTruthy(); - - // Current time readout. - const currentTime = await avPage.$eval(".mejs__currenttime", (el) => - el.textContent.trim() - ); - expect(currentTime).toMatch(/^\d{2}:\d{2}/); - }, 60000); - }); - - // AV MANIFEST WITH TABLE OF CONTENTS TEST - // An AV manifest that defines ranges/structures is routed to the AV extension - // (AVCenterPanel / iiif-av-component) rather than the mediaelement player. - describe("AV manifest with table of contents", () => { - // Use a dedicated page so the AV tests are isolated from whatever state - // (e.g. in-flight iframe loads) earlier suites left on the shared page. - let avPage; - - beforeAll(async () => { - avPage = await browser.newPage(); - }); - - afterAll(async () => { - await avPage.close(); - }); - - beforeEach(async () => { - // Force a full reload so the viewer re-initialises on this manifest - // (a hash-only change would keep the previously loaded manifest). - await avPage.goto("about:blank"); - await avPage.goto(viewerUrl(AV_TOC_MANIFEST), { - waitUntil: "domcontentloaded", - }); - }, 60000); - - it("loads the AV manifest into the AV center panel", async () => { - expect(avPage.url()).toContain(encodeURIComponent(AV_TOC_MANIFEST)); - - await avPage.waitForSelector(".uv", { visible: true }); - - // The AV extension mounts the AVComponent into a .iiif-av-component - // wrapper inside the center panel. - await avPage.waitForSelector(".iiif-av-component .player", { - visible: true, - }); - - // This path must NOT fall back to the mediaelement player. - const mejsCount = await avPage.$$eval( - ".mejs__container", - (els) => els.length - ); - expect(mejsCount).toBe(0); - - // The media element is created as video.anno / audio.anno. - await avPage.waitForSelector( - ".iiif-av-component video.anno, .iiif-av-component audio.anno" - ); - - const pageText = await avPage.evaluate(() => document.body.innerText); - expect(pageText).not.toContain("Unable to load"); - expect(pageText).not.toContain("Error loading"); - }, 60000); - - it("renders AV component playback controls", async () => { - await avPage.waitForSelector(".iiif-av-component .controls-container", { - visible: true, - }); - - // Play/pause button. - const playButton = await avPage.$( - ".iiif-av-component .controls-container .av-icon-play" - ); - expect(playButton).toBeTruthy(); - - // Duration display. - const duration = await avPage.$( - ".iiif-av-component .time-display .canvas-duration" - ); - expect(duration).toBeTruthy(); - }, 60000); - }); - - // AV MANIFEST WITH A TRANSCRIPTION TEST - describe("AV manifest with a transcription", () => { - let avPage; - - beforeAll(async () => { - avPage = await browser.newPage(); - }); - - afterAll(async () => { - await avPage.close(); - }); - - beforeEach(async () => { - // Force a full reload so the viewer re-initialises on this manifest. - await avPage.goto("about:blank"); - await avPage.goto(viewerUrl(AV_CAPTIONED_MANIFEST), { - waitUntil: "domcontentloaded", - }); - }, 60000); - - it("surfaces the transcription as a caption track in the player", async () => { - await avPage.waitForSelector(".mejs__container.mejs__video", { - visible: true, - }); - - // The captions button only renders when a caption track was wired up. - await avPage.waitForSelector(".mejs__captions-button", { - visible: true, - }); - - // The points at the manifest's VTT transcription. - const track = await avPage.$eval("track[src*='captions.vtt']", (t) => ({ - kind: t.kind, - srclang: t.srclang, - label: t.label, - })); - expect(track.kind).toBe("subtitles"); - expect(track.srclang).toBe("en"); - expect(track.label).toBe("English captions"); - - // The transcription appears as a selectable option, and becomes - // enabled once the player has loaded the VTT file. - await avPage.waitForFunction(() => { - const input = document.querySelector( - ".mejs__captions-selector input:not([value='none'])" - ); - return input && !input.disabled; - }); - }, 60000); - - it("displays the transcription text during playback", async () => { - await avPage.waitForSelector(".mejs__captions-button", { - visible: true, - }); - - // Wait for the player to finish loading the VTT. - await avPage.waitForFunction(() => { - const input = document.querySelector( - ".mejs__captions-selector input:not([value='none'])" - ); - return input && !input.disabled; - }); - - // Turn captions on through the player UI. - await avPage.evaluate(() => { - document - .querySelector(".mejs__captions-selector input:not([value='none'])") - .click(); - }); - - // Play (muted, so headless autoplay is allowed) to reach the first cue. - await avPage.evaluate(() => { - const video = document.querySelector(".mejs__mediaelement video"); - video.muted = true; - return video.play(); - }); - - await avPage.waitForFunction(() => { - const el = document.querySelector(".mejs__captions-text"); - return el && el.textContent.trim().length > 0; - }); - - const captionText = await avPage.$eval(".mejs__captions-text", (el) => - el.textContent.trim() - ); - expect(captionText).toBe( - "Just before lunch one day, a puppet show was put on at school." - ); - }, 60000); - - it("displays a cross-origin transcription served with CORS headers", async () => { - await avPage.goto("about:blank"); - await avPage.goto(viewerUrl(AV_CROSS_ORIGIN_CAPTIONED_MANIFEST), { - waitUntil: "domcontentloaded", - }); - - await avPage.waitForSelector(".mejs__captions-button", { - visible: true, - }); - - // The points at the cross-origin VTT. - const trackSrc = await avPage.$eval( - "track[src*='lunchroom_manners.vtt']", - (t) => t.src - ); - expect(trackSrc).toBe( - "https://fixtures.iiif.io/video/indiana/lunchroom_manners/lunchroom_manners.vtt" - ); - - // The caption option only becomes enabled once the player has - // successfully fetched the cross-origin VTT. - await avPage.waitForFunction(() => { - const input = document.querySelector( - ".mejs__captions-selector input:not([value='none'])" - ); - return input && !input.disabled; - }); - - // Turn captions on and play; the first cue ("[music]") starts 1.2s in. - await avPage.evaluate(() => { - document - .querySelector(".mejs__captions-selector input:not([value='none'])") - .click(); - const video = document.querySelector(".mejs__mediaelement video"); - video.muted = true; - return video.play(); - }); - - await avPage.waitForFunction(() => { - const el = document.querySelector(".mejs__captions-text"); - return el && el.textContent.trim().length > 0; - }); - - const captionText = await avPage.$eval(".mejs__captions-text", (el) => - el.textContent.trim() - ); - expect(captionText).toBe("[music]"); - }, 60000); - - it("resolves a transcription behind an institutional redirect", async () => { - await avPage.goto("about:blank"); - await avPage.goto(viewerUrl(AV_REDIRECTED_CAPTIONED_MANIFEST), { - waitUntil: "domcontentloaded", - }); - - await avPage.waitForSelector(".mejs__captions-button", { - visible: true, - }); - - // The manifest's http:// URL is unreadable (its redirect hop has no - // CORS headers), so UV must have resolved the track to the readable - // https destination. - const trackSrc = await avPage.$eval( - "track[src*='lunchroom_manners.vtt']", - (t) => t.src - ); - expect(trackSrc).toBe( - "https://dlib.indiana.edu/iiif_av/lunchroom_manners/lunchroom_manners.vtt" - ); - - await avPage.waitForFunction(() => { - const input = document.querySelector( - ".mejs__captions-selector input:not([value='none'])" - ); - return input && !input.disabled; - }); - - await avPage.evaluate(() => { - document - .querySelector(".mejs__captions-selector input:not([value='none'])") - .click(); - const video = document.querySelector(".mejs__mediaelement video"); - video.muted = true; - return video.play(); - }); - - await avPage.waitForFunction(() => { - const el = document.querySelector(".mejs__captions-text"); - return el && el.textContent.trim().length > 0; - }); - - const captionText = await avPage.$eval(".mejs__captions-text", (el) => - el.textContent.trim() - ); - expect(captionText).toBe("[music]"); - }, 60000); - - it("displays a transcription supplied as a supplementing annotation (cookbook 0219)", async () => { - await avPage.goto("about:blank"); - await avPage.goto(viewerUrl(AV_COOKBOOK_CAPTION_MANIFEST), { - waitUntil: "domcontentloaded", - }); - - await avPage.waitForSelector(".mejs__captions-button", { - visible: true, - }); - - // The track carries the annotation body's label and language. - const track = await avPage.$eval( - "track[src*='lunchroom_manners.vtt']", - (t) => ({ label: t.label, srclang: t.srclang }) - ); - expect(track.label).toBe("Captions in WebVTT format"); - expect(track.srclang).toBe("en"); - - await avPage.waitForFunction(() => { - const input = document.querySelector( - ".mejs__captions-selector input:not([value='none'])" - ); - return input && !input.disabled; - }); - - await avPage.evaluate(() => { - document - .querySelector(".mejs__captions-selector input:not([value='none'])") - .click(); - const video = document.querySelector(".mejs__mediaelement video"); - video.muted = true; - return video.play(); - }); - - await avPage.waitForFunction(() => { - const el = document.querySelector(".mejs__captions-text"); - return el && el.textContent.trim().length > 0; - }); - - const captionText = await avPage.$eval(".mejs__captions-text", (el) => - el.textContent.trim() - ); - expect(captionText).toBe("[music]"); - }, 60000); - - it("displays a transcription from an externally referenced annotation page", async () => { - await avPage.goto("about:blank"); - await avPage.goto(viewerUrl(AV_SUPPLEMENTING_EXTERNAL_MANIFEST), { - waitUntil: "domcontentloaded", - }); - - await avPage.waitForSelector(".mejs__captions-button", { - visible: true, - }); - - await avPage.waitForFunction(() => { - const input = document.querySelector( - ".mejs__captions-selector input:not([value='none'])" - ); - return input && !input.disabled; - }); - - await avPage.evaluate(() => { - document - .querySelector(".mejs__captions-selector input:not([value='none'])") - .click(); - const video = document.querySelector(".mejs__mediaelement video"); - video.muted = true; - return video.play(); - }); - - await avPage.waitForFunction(() => { - const el = document.querySelector(".mejs__captions-text"); - return el && el.textContent.trim().length > 0; - }); - - const captionText = await avPage.$eval(".mejs__captions-text", (el) => - el.textContent.trim() - ); - expect(captionText).toBe( - "Just before lunch one day, a puppet show was put on at school." - ); - }, 60000); - }); -}); diff --git a/__tests__/image_tests.js b/__tests__/image_tests.js deleted file mode 100644 index f9d23bf10..000000000 --- a/__tests__/image_tests.js +++ /dev/null @@ -1,410 +0,0 @@ -const puppeteer = require("puppeteer"); -const { BASE_URL } = require("../scripts/testBaseUrl"); - -// Cookbook manifest for viewer control tests -const COOKBOOK_BOUND_MULTIVOLUME_MANIFEST = - "https://iiif.io/api/cookbook/recipe/0031-bound-multivolume/manifest.json"; - -const viewerUrl = (manifestUrl) => { - //const separator = BASE_URL.includes("#?") ? "&" : "#?"; - return `${BASE_URL}#?manifest=${encodeURIComponent(manifestUrl)}`; -}; - -describe("Universal Viewer", () => { - let browser; - let page; - - const getRotationFromNavigator = async () => { - return await page.evaluate(() => { - const el = document.querySelector(".displayregioncontainer"); - if (!el) return 0; - - const transform = el.style.transform || ""; - const match = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/); - if (!match) return 0; - - const deg = Number(match[1]); - return ((deg % 360) + 360) % 360; - }); - }; - - const waitForRotation = async (expectedRotation) => { - await page.waitForFunction( - (expected) => { - const el = document.querySelector(".displayregioncontainer"); - if (!el) return false; - - const transform = el.style.transform || ""; - const match = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/); - const current = match ? Number(match[1]) : 0; - - return ((current % 360) + 360) % 360 === expected; - }, - {}, - expectedRotation - ); - }; - - const getCanvasValue = (url) => { - const match = url.match(/(?:^|[?&#])(cv|canvas|page)=([^&#]*)/); - if (!match) return 0; - - const rawValue = match[2]; - if (rawValue === "") return 0; - - const value = Number(rawValue); - if (Number.isNaN(value)) return 0; - - return value; - }; - - const waitForCanvasValue = async (page, expected) => { - await page.waitForFunction( - (expectedValue) => { - const match = window.location.href.match( - /(?:^|[?&#])(cv|canvas|page)=([^&#]*)/ - ); - if (!match) return expectedValue === 0; - - const rawValue = match[2]; - if (rawValue === "") return expectedValue === 0; - - const value = Number(rawValue); - return value === expectedValue; - }, - {}, - expected - ); - }; - - const getXywhValue = (url) => { - const match = url.match(/[?&#]xywh=([^&]+)/); - return match ? decodeURIComponent(match[1]) : null; - }; - - beforeAll(async () => { - browser = await puppeteer.launch(); - page = await browser.newPage(); - await page.goto(BASE_URL); - }); - - afterAll(async () => { - await browser.close(); - }); - - // Default manifest test - it("has the correct page title", async () => { - const title = await page.title(); - expect(title).toBe("Universal Viewer Examples"); - }); - - it("loads the viewer images", async () => { - await page.waitForSelector("#thumb-0"); - const imageSrc = await page.$eval("#thumb-0 img", (e) => e.src); - expect(imageSrc).toEqual( - expect.stringContaining( - "https://iiif.wellcomecollection.org/image/b18035723_0001.JP2/full/90,/0/default.jpg" - ) - ); - }); - - it("can toggle thumbnail label truncation", async () => { - await page.waitForSelector("#truncateThumbnailLabels"); - - const isCheckedBeforeToggle = await page.$eval( - "#truncateThumbnailLabels", - (checkbox) => checkbox.checked - ); - expect(isCheckedBeforeToggle).toBe(true); - - const labelOverflowBeforeToggle = await page.evaluate(() => { - const label = document.querySelector( - ".thumbsView .thumbs .thumb .info .label" - ); - return getComputedStyle(label).overflowX; - }); - expect(labelOverflowBeforeToggle).toBe("hidden"); - - await page.evaluate(() => { - document.querySelector("#truncateThumbnailLabels").click(); - }); - - const isCheckedAfterToggle = await page.$eval( - "#truncateThumbnailLabels", - (checkbox) => checkbox.checked - ); - expect(isCheckedAfterToggle).toBe(false); - - const labelOverflowAfterToggle = await page.evaluate(() => { - const label = document.querySelector( - ".thumbsView .thumbs .thumb .info .label" - ); - return getComputedStyle(label).overflowX; - }); - expect(labelOverflowAfterToggle).toBe("visible"); - }); - - it("can toggle gallery view", async () => { - // gallery view is not default view - const galleryViewBeforeToggle = await page.evaluate(() => { - const galleryViewOverlay = document.querySelector( - ".iiif-gallery-component .header" - ); - return getComputedStyle(galleryViewOverlay).overflowX; - }); - expect(galleryViewBeforeToggle).toBe("hidden"); - - // gallery toggle icon is visible - await page.waitForSelector(".uv-icon-gallery"); - const galleryViewToggle = await page.evaluate(() => { - const toggle = document.querySelector(".uv-icon-gallery"); - return getComputedStyle(toggle).overflowX; - }); - expect(galleryViewToggle).toBe("visible"); - - // gallery view can be toggled on - await page.evaluate(() => { - document.querySelector(".uv-icon-gallery").click(); - }); - const galleryViewAfterToggle = await page.evaluate(() => { - const galleryViewOverlay = document.querySelector( - ".iiif-gallery-component" - ); - return getComputedStyle(galleryViewOverlay).overflowX; - }); - expect(galleryViewAfterToggle).toBe("visible"); - - // gallery view can be toggled off - await page.evaluate(() => { - document.querySelector(".uv-icon-two-up").click(); - }); - const galleryViewAfterTwoUpToggle = await page.evaluate(() => { - const galleryViewOverlay = document.querySelector( - ".iiif-gallery-component .header" - ); - return getComputedStyle(galleryViewOverlay).overflowX; - }); - expect(galleryViewAfterTwoUpToggle).toBe("hidden"); - }); - - it("settings button is visible", async () => { - await page.waitForSelector(".btn.imageBtn.settings"); - - const isSettingsButtonVisible = await page.evaluate(() => { - const settingsButton = document.querySelector(".btn.imageBtn.settings"); - const style = window.getComputedStyle(settingsButton); - return ( - style.getPropertyValue("visibility") !== "hidden" && - style.getPropertyValue("display") !== "none" - ); - }); - - expect(isSettingsButtonVisible).toBe(true); - }); - - // COOKBOOK MANIFEST TEST - describe("viewer controls", () => { - beforeEach(async () => { - await page.goto(viewerUrl(COOKBOOK_BOUND_MULTIVOLUME_MANIFEST), { - waitUntil: "domcontentloaded", - }); - }); - - // can navigate back and forth - it("can navigate back and forth", async () => { - await page.waitForSelector(".btn.imageBtn.next", { visible: true }); - await page.waitForSelector(".btn.imageBtn.prev", { visible: true }); - - const startValue = getCanvasValue(page.url()); - - const isPrevDisabledInitially = await page.$eval( - ".btn.imageBtn.prev", - (btn) => btn.disabled - ); - expect(isPrevDisabledInitially).toBe(true); - - await page.click(".btn.imageBtn.next"); - await waitForCanvasValue(page, 1); - - const nextValue = getCanvasValue(page.url()); - const isPrevDisabledAfterNext = await page.$eval( - ".btn.imageBtn.prev", - (btn) => btn.disabled - ); - expect(isPrevDisabledAfterNext).toBe(false); - - await page.click(".btn.imageBtn.prev"); - await waitForCanvasValue(page, 0); - - const previousValue = getCanvasValue(page.url()); - const isPrevDisabledAgain = await page.$eval( - ".btn.imageBtn.prev", - (btn) => btn.disabled - ); - expect(isPrevDisabledAgain).toBe(true); - - expect(startValue).toBe(0); - expect(nextValue).toBe(1); - expect(previousValue).toBe(0); - }); - - // zoom in and zoom out - it("can zoom in and zoom out", async () => { - await page.waitForSelector(".zoomIn.viewportNavButton", { - visible: true, - }); - await page.waitForSelector(".zoomOut.viewportNavButton", { - visible: true, - }); - - const initialUrl = page.url(); - const initialXywh = getXywhValue(initialUrl); - - await page.$eval(".zoomIn.viewportNavButton", (el) => el.click()); - await page.waitForFunction( - (prev) => window.location.href !== prev, - {}, - initialUrl - ); - - const zoomInUrl = page.url(); - const zoomInXywh = getXywhValue(zoomInUrl); - - expect(zoomInXywh).not.toBeNull(); - expect(zoomInXywh).not.toBe(initialXywh); - expect(zoomInXywh).toMatch(/^-?\d+,-?\d+,\d+,\d+$/); - - await page.$eval(".zoomOut.viewportNavButton", (el) => el.click()); - await page.waitForFunction( - (prev) => window.location.href !== prev, - {}, - zoomInUrl - ); - - const zoomOutUrl = page.url(); - const zoomOutXywh = getXywhValue(zoomOutUrl); - - expect(zoomOutXywh).not.toBeNull(); - expect(zoomOutXywh).not.toBe(zoomInXywh); - expect(zoomOutXywh).toMatch(/^-?\d+,-?\d+,\d+,\d+$/); - }); - - // rotate image - it("can rotate image", async () => { - await page.waitForSelector(".rotate.viewportNavButton", { - visible: true, - }); - - const initialRot = await getRotationFromNavigator(); - - await page.$eval(".rotate.viewportNavButton", (el) => el.click()); - await waitForRotation(90); - - const rotatedRot = await getRotationFromNavigator(); - expect(initialRot).toBe(0); - expect(rotatedRot).toBe(90); - }); - - // open and close adjust image control - it("can open and close adjust image control", async () => { - const btn = "button.viewportNavButton.adjustImage"; - const overlay = "div.overlay.adjustImage"; - const heading = "div.overlay.adjustImage .content .heading"; - const closeBtn = ".btn.btn-default.close"; - - await page.waitForSelector(btn, { visible: true }); - await page.$eval(btn, (el) => el.click()); - - await page.waitForSelector(overlay, { visible: true }); - - const text = await page.$eval(heading, (el) => el.textContent.trim()); - expect(text).toBe("Adjust image"); - - await page.$eval(closeBtn, (el) => el.click()); - - const isOverlayVisible = await page.evaluate(() => { - const isOverlayVisible = document.querySelector( - "div.overlay.adjustImage" - ); - const style = window.getComputedStyle(isOverlayVisible); - - return ( - style.getPropertyValue("display") === "none" || - style.getPropertyValue("visibility") === "hidden" - ); - }); - expect(isOverlayVisible).toBe(false); - }); - }); - - describe("content panel", () => { - const contentExpandBtn = "button.expandButton"; - const contentCollapseBtn = "button.collapseButton"; - const contentTabs = "div.leftPanel"; - const contentIndexTab = ".index.tab"; - const contentIndexActiveTab = ".index.tab.on"; - const contentThumbnailsTab = ".thumbs.tab"; - const contentThumbnailsActiveTab = ".thumbs.tab.on"; - - beforeEach(async () => { - await page.goto(BASE_URL); - }); - - // switch content tabs and collapse content panel - it("can switch content tabs", async () => { - await page.waitForSelector(contentThumbnailsActiveTab, { visible: true }); - - await page.click(contentIndexTab); - await page.waitForSelector(contentIndexActiveTab, { visible: true }); - - expect( - await page.$eval(contentIndexTab, (el) => el.classList.contains("on")) - ).toBe(true); - - expect( - await page.$eval(contentThumbnailsTab, (el) => - el.classList.contains("on") - ) - ).toBe(false); - }); - - it("can collapse content", async () => { - await page.waitForSelector(contentTabs, { visible: true }); - await page.waitForSelector(contentCollapseBtn, { visible: true }); - - await page.click(contentCollapseBtn); - - await page.waitForSelector(contentExpandBtn, { visible: true }); - await page.waitForSelector(".leftPanel .tabs", { hidden: true }); - }); - }); - - describe("more information panel", () => { - const moreInfoExpandBtn = ".rightPanel button.expandButton"; - const moreInfoCollapseBtn = ".rightPanel button.collapseButton"; - const moreInfoHeader = ".rightPanel div.header"; - - beforeEach(async () => { - await page.goto(BASE_URL); - }); - - it("can expand and collapse moreInformation panel", async () => { - await page.waitForSelector(moreInfoExpandBtn, { visible: true }); - await page.click(moreInfoExpandBtn); - - // verify expanded state and header - await page.waitForSelector(moreInfoCollapseBtn, { visible: true }); - await page.waitForSelector(moreInfoHeader, { visible: true }); - - const headers = await page.$$eval(moreInfoHeader, (els) => - els.map((el) => el.textContent.trim()) - ); - - expect(headers).toContain("About the item"); - - await page.click(moreInfoCollapseBtn); - await page.waitForSelector(moreInfoExpandBtn, { visible: true }); - await page.waitForSelector(moreInfoHeader, { hidden: true }); - }); - }); -}); diff --git a/__tests__/pdf_tests.js b/__tests__/pdf_tests.js deleted file mode 100644 index e18b9cf66..000000000 --- a/__tests__/pdf_tests.js +++ /dev/null @@ -1,142 +0,0 @@ -const puppeteer = require("puppeteer"); -const { BASE_URL } = require("../scripts/testBaseUrl"); - -// PDF manifest for PDF-specific behaviour -const PDF_MULTI_FILE_MANIFEST = - "https://digital.library.villanova.edu/Item/vudl:294631/Manifest"; - -const viewerUrl = (manifestUrl) => { - //const separator = BASE_URL.includes("#?") ? "&" : "#?"; - return `${BASE_URL}#?manifest=${encodeURIComponent(manifestUrl)}`; -}; - -describe("Universal Viewer", () => { - let browser; - let page; - - beforeAll(async () => { - browser = await puppeteer.launch({ - headless: true, - args: ["--no-sandbox", "--disable-setuid-sandbox"], - }); - page = await browser.newPage(); - }); - - afterAll(async () => { - await browser.close(); - }); - - // PDF MANIFEST TEST - describe("PDF manifest", () => { - beforeEach(async () => { - await page.goto(viewerUrl(PDF_MULTI_FILE_MANIFEST), { - waitUntil: "domcontentloaded", - }); - }); - - it("loads PDF manifest successfully", async () => { - expect(page.url()).toContain(encodeURIComponent(PDF_MULTI_FILE_MANIFEST)); - - await page.waitForSelector(".uv", { visible: true }); - - await page.waitForFunction(() => { - return document.querySelectorAll("iframe").length > 0; - }); - - const viewerFrame = page.frames().find((f) => { - const url = f.url(); - - return ( - url.includes("uv.html") || - url.includes("viewer") || - url.includes("manifest") - ); - }); - - expect(viewerFrame).toBeTruthy(); - - await viewerFrame.waitForSelector("canvas", { visible: true }); - - const canvasInfo = await viewerFrame.evaluate(() => { - const canvas = document.querySelector("canvas"); - - if (!canvas) return null; - return { - width: canvas.width, - height: canvas.height, - }; - }); - expect(canvasInfo).not.toBeNull(); - expect(canvasInfo.width).toBeGreaterThan(0); - expect(canvasInfo.height).toBeGreaterThan(0); - - const pageText = await viewerFrame.evaluate( - () => document.body.innerText - ); - - expect(pageText).not.toContain("Unable to load"); - expect(pageText).not.toContain("Error loading"); - }); - - it("shows multiple PDF files in the sidebar and allows navigation", async () => { - // In a fresh browser session the left panel opens automatically - // (panelOpen defaults to true), so wait for it rather than clicking - // the expand button, which is only visible while the panel is closed. - await page.waitForSelector(".leftPanel.open", { visible: true }); - - await page.waitForSelector(".thumb", { visible: true }); - - const thumbs = await page.$$(".thumb"); - - expect(thumbs.length).toBeGreaterThan(1); - await thumbs[1].click(); - - await page.waitForFunction(() => window.location.href.includes("cv=1")); - - expect(page.url()).toContain("cv=1"); - }); - - it("can collapse and re-expand the sidebar with the expand button", async () => { - // The sidebar opens automatically, so collapse it first to make the - // expand button available. The open-finished class is toggled once - // the panel animation completes. - await page.waitForSelector(".leftPanel.open-finished", { - visible: true, - }); - await page.click(".leftPanel button.collapseButton"); - - // Collapsed: the panel content hides and the expand button appears. - await page.waitForFunction(() => { - const panel = document.querySelector(".leftPanel"); - return panel && !panel.classList.contains("open-finished"); - }); - await page.waitForSelector(".leftPanel button.expandButton", { - visible: true, - }); - await page.waitForSelector(".leftPanel .tabs", { hidden: true }); - - expect( - await page.$eval(".leftPanel button.expandButton", (btn) => - btn.getAttribute("aria-expanded") - ) - ).toBe("false"); - - await page.click(".leftPanel button.expandButton"); - - // Expanded again: the panel reopens and the thumbnails are visible. - await page.waitForSelector(".leftPanel.open-finished", { - visible: true, - }); - await page.waitForSelector(".thumb", { visible: true }); - - expect( - await page.$eval(".leftPanel", (el) => el.classList.contains("open")) - ).toBe(true); - expect( - await page.$eval(".leftPanel button.expandButton", (btn) => - btn.getAttribute("aria-expanded") - ) - ).toBe("true"); - }); - }); -}); diff --git a/src/test-fixtures/captioned-video-manifest.json b/src/test-fixtures/captioned-video-manifest.json deleted file mode 100644 index 97c8b62ec..000000000 --- a/src/test-fixtures/captioned-video-manifest.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/3/context.json", - "id": "http://localhost:4444/test-fixtures/captioned-video-manifest.json", - "type": "Manifest", - "label": { - "en": ["Video with captions (e2e test fixture)"] - }, - "items": [ - { - "id": "http://localhost:4444/test-fixtures/captioned-video-manifest.json/canvas", - "type": "Canvas", - "height": 360, - "width": 480, - "duration": 572.034, - "items": [ - { - "id": "http://localhost:4444/test-fixtures/captioned-video-manifest.json/canvas/page", - "type": "AnnotationPage", - "items": [ - { - "id": "http://localhost:4444/test-fixtures/captioned-video-manifest.json/canvas/page/annotation", - "type": "Annotation", - "motivation": "painting", - "target": "http://localhost:4444/test-fixtures/captioned-video-manifest.json/canvas", - "body": [ - { - "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/high/lunchroom_manners_1024kb.mp4", - "type": "Video", - "height": 360, - "width": 480, - "duration": 572.034, - "format": "video/mp4" - }, - { - "id": "http://localhost:4444/test-fixtures/captions.vtt", - "type": "Text", - "format": "text/vtt", - "label": "English captions", - "language": "en" - } - ] - } - ] - } - ] - } - ] -} diff --git a/src/test-fixtures/captions.vtt b/src/test-fixtures/captions.vtt deleted file mode 100644 index f0903de25..000000000 --- a/src/test-fixtures/captions.vtt +++ /dev/null @@ -1,9 +0,0 @@ -WEBVTT - -1 -00:00:00.000 --> 00:00:20.000 -Just before lunch one day, a puppet show was put on at school. - -2 -00:00:20.100 --> 00:00:40.000 -It was called "Mister Bungle Goes to Lunch". diff --git a/src/test-fixtures/cross-origin-captioned-video-manifest.json b/src/test-fixtures/cross-origin-captioned-video-manifest.json deleted file mode 100644 index d9859b06e..000000000 --- a/src/test-fixtures/cross-origin-captioned-video-manifest.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/3/context.json", - "id": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json", - "type": "Manifest", - "label": { - "en": ["Video with cross-origin captions (e2e test fixture)"] - }, - "items": [ - { - "id": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json/canvas", - "type": "Canvas", - "height": 360, - "width": 480, - "duration": 572.034, - "items": [ - { - "id": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json/canvas/page", - "type": "AnnotationPage", - "items": [ - { - "id": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json/canvas/page/annotation", - "type": "Annotation", - "motivation": "painting", - "target": "http://localhost:4444/test-fixtures/cross-origin-captioned-video-manifest.json/canvas", - "body": [ - { - "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/high/lunchroom_manners_1024kb.mp4", - "type": "Video", - "height": 360, - "width": 480, - "duration": 572.034, - "format": "video/mp4" - }, - { - "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/lunchroom_manners.vtt", - "type": "Text", - "format": "text/vtt", - "label": "English captions", - "language": "en" - } - ] - } - ] - } - ] - } - ] -} diff --git a/src/test-fixtures/redirected-captioned-video-manifest.json b/src/test-fixtures/redirected-captioned-video-manifest.json deleted file mode 100644 index eecad2c41..000000000 --- a/src/test-fixtures/redirected-captioned-video-manifest.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/3/context.json", - "id": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json", - "type": "Manifest", - "label": { - "en": ["Video with captions behind a redirect (e2e test fixture)"] - }, - "items": [ - { - "id": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json/canvas", - "type": "Canvas", - "height": 360, - "width": 480, - "duration": 572.034, - "items": [ - { - "id": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json/canvas/page", - "type": "AnnotationPage", - "items": [ - { - "id": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json/canvas/page/annotation", - "type": "Annotation", - "motivation": "painting", - "target": "http://localhost:4444/test-fixtures/redirected-captioned-video-manifest.json/canvas", - "body": [ - { - "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/high/lunchroom_manners_1024kb.mp4", - "type": "Video", - "height": 360, - "width": 480, - "duration": 572.034, - "format": "video/mp4" - }, - { - "id": "http://dlib.indiana.edu/iiif_av/lunchroom_manners/lunchroom_manners.vtt", - "type": "Text", - "format": "text/vtt", - "label": "English captions", - "language": "en" - } - ] - } - ] - } - ] - } - ] -} diff --git a/src/test-fixtures/supplementing-annotation-page.json b/src/test-fixtures/supplementing-annotation-page.json deleted file mode 100644 index 7b6a7ad2d..000000000 --- a/src/test-fixtures/supplementing-annotation-page.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/3/context.json", - "id": "http://localhost:4444/test-fixtures/supplementing-annotation-page.json", - "type": "AnnotationPage", - "items": [ - { - "id": "http://localhost:4444/test-fixtures/supplementing-annotation-page.json/annotation", - "type": "Annotation", - "motivation": "supplementing", - "body": { - "id": "http://localhost:4444/test-fixtures/captions.vtt", - "type": "Text", - "format": "text/vtt", - "label": { - "en": ["English captions"] - }, - "language": "en" - }, - "target": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas" - } - ] -} diff --git a/src/test-fixtures/supplementing-external-captioned-video-manifest.json b/src/test-fixtures/supplementing-external-captioned-video-manifest.json deleted file mode 100644 index a4923a7b6..000000000 --- a/src/test-fixtures/supplementing-external-captioned-video-manifest.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/3/context.json", - "id": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json", - "type": "Manifest", - "label": { - "en": [ - "Video with captions in an external annotation page (e2e test fixture)" - ] - }, - "items": [ - { - "id": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas", - "type": "Canvas", - "height": 360, - "width": 480, - "duration": 572.034, - "items": [ - { - "id": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas/page", - "type": "AnnotationPage", - "items": [ - { - "id": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas/page/annotation", - "type": "Annotation", - "motivation": "painting", - "target": "http://localhost:4444/test-fixtures/supplementing-external-captioned-video-manifest.json/canvas", - "body": { - "id": "https://fixtures.iiif.io/video/indiana/lunchroom_manners/high/lunchroom_manners_1024kb.mp4", - "type": "Video", - "height": 360, - "width": 480, - "duration": 572.034, - "format": "video/mp4" - } - } - ] - } - ], - "annotations": [ - { - "id": "http://localhost:4444/test-fixtures/supplementing-annotation-page.json", - "type": "AnnotationPage" - } - ] - } - ] -} From 6bc594e5e5198f4cc5073afa87542aea1a5bdf5a Mon Sep 17 00:00:00 2001 From: K8Sewell Date: Thu, 2 Jul 2026 16:50:22 -0500 Subject: [PATCH 7/9] Revert file unrelevant to this work --- .../MediaElementCenterPanel.ts | 121 +----------------- 1 file changed, 3 insertions(+), 118 deletions(-) diff --git a/src/content-handlers/iiif/modules/uv-mediaelementcenterpanel-module/MediaElementCenterPanel.ts b/src/content-handlers/iiif/modules/uv-mediaelementcenterpanel-module/MediaElementCenterPanel.ts index 154c587c3..6cea0dfb3 100644 --- a/src/content-handlers/iiif/modules/uv-mediaelementcenterpanel-module/MediaElementCenterPanel.ts +++ b/src/content-handlers/iiif/modules/uv-mediaelementcenterpanel-module/MediaElementCenterPanel.ts @@ -27,17 +27,6 @@ type TextTrackDescriptor = { id: string; }; -const captionTypes = new Set(["text/vtt", "text/srt"]); - -// A label in raw annotation JSON may be a plain string or a language map. -const captionLabel = (label: any): string | undefined => { - if (!label || typeof label === "string") { - return label || undefined; - } - const values = label[Object.keys(label)[0]]; - return Array.isArray(values) ? values[0] : undefined; -}; - type MediaSourceDescriptor = { label: string; type: string; @@ -217,23 +206,7 @@ export class MediaElementCenterPanel extends CenterPanel< } } - // Captions may also be supplied as supplementing annotations on the - // canvas (IIIF cookbook recipe 0219). - const supplementing = await this.getSupplementingCaptions(canvas); - for (const caption of supplementing) { - if (!subtitles.some((subtitle) => subtitle.id === caption.id)) { - subtitles.push(caption); - } - } - if (subtitles.length > 0) { - // Resolve caption URLs to ones the player's XHR will be able to read. - for (const subtitle of subtitles) { - if (subtitle.id) { - subtitle.id = await this.resolveCaptionSource(subtitle.id); - } - } - // Show captions options popover for better interface feedback subtitles.unshift({ id: "none" }); } @@ -419,96 +392,6 @@ export class MediaElementCenterPanel extends CenterPanel< this.extensionHost.publish(Events.LOAD); } - // Captions/transcriptions supplied as supplementing annotations in the - // canvas' annotations pages (IIIF cookbook recipe 0219). Inline - // annotation pages are read directly; pages referenced by id alone are - // fetched. - async getSupplementingCaptions( - canvas: Canvas - ): Promise { - const captions: TextTrackDescriptor[] = []; - const pages: any[] = canvas.getProperty("annotations") || []; - - for (const page of pages) { - let items: any[] = page.items; - - if (!items && page.id) { - try { - const response = await fetch(page.id); - if (response.ok) { - items = (await response.json()).items; - } - } catch { - console.warn( - `Annotation page ${page.id} could not be read (CORS headers are required); any captions it contains will be unavailable.` - ); - } - } - - if (!items) { - continue; - } - - for (const annotation of items) { - const motivations = Array.isArray(annotation.motivation) - ? annotation.motivation - : [annotation.motivation]; - - if (!motivations.includes("supplementing")) { - continue; - } - - const bodies = Array.isArray(annotation.body) - ? annotation.body - : [annotation.body]; - - for (const body of bodies) { - if (body && body.id && captionTypes.has(body.format)) { - captions.push({ - id: body.id, - label: captionLabel(body.label), - language: body.language, - }); - } - } - } - } - - return captions; - } - - // Captions are fetched with XHR by the player, so a cross-origin URL is - // only readable when every response hop carries CORS headers. Follow any - // CORS-friendly redirect to its final URL, and retry plain-http sources - // over https — an http -> https upgrade redirect whose 301 response lacks - // CORS headers is blocked by the browser even though its destination is - // readable. - async resolveCaptionSource(src: string): Promise { - const attempts: string[] = [src]; - - if (src.startsWith("http://")) { - attempts.push(src.replace(/^http:\/\//, "https://")); - } - - for (const attempt of attempts) { - try { - const response = await fetch(attempt); - if (response.ok) { - return response.url; - } - } catch { - // expected when the attempt is blocked by CORS or mixed content; - // fall through to the next candidate - } - } - - console.warn( - `Captions at ${src} could not be read (CORS headers are required on every response, including redirects); the player will omit this track.` - ); - - return src; - } - appendTextTracks(subtitles: Array) { for (const subtitle of subtitles) { this.$media.append( @@ -546,7 +429,7 @@ export class MediaElementCenterPanel extends CenterPanel< return typeGroup === "audio" || typeGroup === "video"; } - // vtt, srt + // vtt, srt, csv isTypeCaption(element: Rendering | AnnotationBody) { const type: RenderingFormat | MediaType | null = element.getFormat(); @@ -554,6 +437,8 @@ export class MediaElementCenterPanel extends CenterPanel< return false; } + const captionTypes = new Set(["text/vtt", "text/srt"]); + return captionTypes.has(type.toString()); } From d2a804923c07fb5bcb06842753e0789c68461a0b Mon Sep 17 00:00:00 2001 From: K8Sewell Date: Thu, 2 Jul 2026 16:52:17 -0500 Subject: [PATCH 8/9] Revert file unrelevant to this work --- __tests__/test.js | 486 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 __tests__/test.js diff --git a/__tests__/test.js b/__tests__/test.js new file mode 100644 index 000000000..8c65f3873 --- /dev/null +++ b/__tests__/test.js @@ -0,0 +1,486 @@ +test.skip("Configuration options", () => {}); + +const puppeteer = require("puppeteer"); +const { BASE_URL } = require("../scripts/testBaseUrl"); + +// Cookbook manifest for viewer control tests +const COOKBOOK_BOUND_MULTIVOLUME_MANIFEST = + "https://iiif.io/api/cookbook/recipe/0031-bound-multivolume/manifest.json"; + +// PDF manifest for PDF-specific behaviour +const PDF_MULTI_FILE_MANIFEST = + "https://digital.library.villanova.edu/Item/vudl:294631/Manifest"; + +const viewerUrl = (manifestUrl) => { + //const separator = BASE_URL.includes("#?") ? "&" : "#?"; + return `${BASE_URL}#?manifest=${encodeURIComponent(manifestUrl)}`; +}; + +describe("Universal Viewer", () => { + let browser; + let page; + + const getRotationFromNavigator = async () => { + return await page.evaluate(() => { + const el = document.querySelector(".displayregioncontainer"); + if (!el) return 0; + + const transform = el.style.transform || ""; + const match = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/); + if (!match) return 0; + + const deg = Number(match[1]); + return ((deg % 360) + 360) % 360; + }); + }; + + const waitForRotation = async (expectedRotation) => { + await page.waitForFunction( + (expected) => { + const el = document.querySelector(".displayregioncontainer"); + if (!el) return false; + + const transform = el.style.transform || ""; + const match = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/); + const current = match ? Number(match[1]) : 0; + + return ((current % 360) + 360) % 360 === expected; + }, + {}, + expectedRotation + ); + }; + + const getCanvasValue = (url) => { + const match = url.match(/(?:^|[?&#])(cv|canvas|page)=([^&#]*)/); + if (!match) return 0; + + const rawValue = match[2]; + if (rawValue === "") return 0; + + const value = Number(rawValue); + if (Number.isNaN(value)) return 0; + + return value; + }; + + const waitForCanvasValue = async (page, expected) => { + await page.waitForFunction( + (expectedValue) => { + const match = window.location.href.match( + /(?:^|[?&#])(cv|canvas|page)=([^&#]*)/ + ); + if (!match) return expectedValue === 0; + + const rawValue = match[2]; + if (rawValue === "") return expectedValue === 0; + + const value = Number(rawValue); + return value === expectedValue; + }, + {}, + expected + ); + }; + + const getXywhValue = (url) => { + const match = url.match(/[?&#]xywh=([^&]+)/); + return match ? decodeURIComponent(match[1]) : null; + }; + + beforeAll(async () => { + browser = await puppeteer.launch(); + page = await browser.newPage(); + await page.goto(BASE_URL); + }); + + afterAll(async () => { + await browser.close(); + }); + + // Default manifest test + it("has the correct page title", async () => { + const title = await page.title(); + expect(title).toBe("Universal Viewer Examples"); + }); + + it("loads the viewer images", async () => { + await page.waitForSelector("#thumb-0"); + const imageSrc = await page.$eval("#thumb-0 img", (e) => e.src); + expect(imageSrc).toEqual( + expect.stringContaining( + "https://iiif.wellcomecollection.org/image/b18035723_0001.JP2/full/90,/0/default.jpg" + ) + ); + }); + + it("can toggle thumbnail label truncation", async () => { + await page.waitForSelector("#truncateThumbnailLabels"); + + const isCheckedBeforeToggle = await page.$eval( + "#truncateThumbnailLabels", + (checkbox) => checkbox.checked + ); + expect(isCheckedBeforeToggle).toBe(true); + + const labelOverflowBeforeToggle = await page.evaluate(() => { + const label = document.querySelector( + ".thumbsView .thumbs .thumb .info .label" + ); + return getComputedStyle(label).overflowX; + }); + expect(labelOverflowBeforeToggle).toBe("hidden"); + + await page.evaluate(() => { + document.querySelector("#truncateThumbnailLabels").click(); + }); + + const isCheckedAfterToggle = await page.$eval( + "#truncateThumbnailLabels", + (checkbox) => checkbox.checked + ); + expect(isCheckedAfterToggle).toBe(false); + + const labelOverflowAfterToggle = await page.evaluate(() => { + const label = document.querySelector( + ".thumbsView .thumbs .thumb .info .label" + ); + return getComputedStyle(label).overflowX; + }); + expect(labelOverflowAfterToggle).toBe("visible"); + }); + + it("can toggle gallery view", async () => { + // gallery view is not default view + const galleryViewBeforeToggle = await page.evaluate(() => { + const galleryViewOverlay = document.querySelector( + ".iiif-gallery-component .header" + ); + return getComputedStyle(galleryViewOverlay).overflowX; + }); + expect(galleryViewBeforeToggle).toBe("hidden"); + + // gallery toggle icon is visible + await page.waitForSelector(".uv-icon-gallery"); + const galleryViewToggle = await page.evaluate(() => { + const toggle = document.querySelector(".uv-icon-gallery"); + return getComputedStyle(toggle).overflowX; + }); + expect(galleryViewToggle).toBe("visible"); + + // gallery view can be toggled on + await page.evaluate(() => { + document.querySelector(".uv-icon-gallery").click(); + }); + const galleryViewAfterToggle = await page.evaluate(() => { + const galleryViewOverlay = document.querySelector( + ".iiif-gallery-component" + ); + return getComputedStyle(galleryViewOverlay).overflowX; + }); + expect(galleryViewAfterToggle).toBe("visible"); + + // gallery view can be toggled off + await page.evaluate(() => { + document.querySelector(".uv-icon-two-up").click(); + }); + const galleryViewAfterTwoUpToggle = await page.evaluate(() => { + const galleryViewOverlay = document.querySelector( + ".iiif-gallery-component .header" + ); + return getComputedStyle(galleryViewOverlay).overflowX; + }); + expect(galleryViewAfterTwoUpToggle).toBe("hidden"); + }); + + it("settings button is visible", async () => { + await page.waitForSelector(".btn.imageBtn.settings"); + + const isSettingsButtonVisible = await page.evaluate(() => { + const settingsButton = document.querySelector(".btn.imageBtn.settings"); + const style = window.getComputedStyle(settingsButton); + return ( + style.getPropertyValue("visibility") !== "hidden" && + style.getPropertyValue("display") !== "none" + ); + }); + + expect(isSettingsButtonVisible).toBe(true); + }); + + // COOKBOOK MANIFEST TEST + describe("viewer controls", () => { + beforeEach(async () => { + await page.goto(viewerUrl(COOKBOOK_BOUND_MULTIVOLUME_MANIFEST), + { + waitUntil: "domcontentloaded" + }); + }); + + // can navigate back and forth + it("can navigate back and forth", async () => { + await page.waitForSelector(".btn.imageBtn.next", { visible: true }); + await page.waitForSelector(".btn.imageBtn.prev", { visible: true }); + + const startValue = getCanvasValue(page.url()); + + const isPrevDisabledInitially = await page.$eval( + ".btn.imageBtn.prev", + (btn) => btn.disabled + ); + expect(isPrevDisabledInitially).toBe(true); + + await page.click(".btn.imageBtn.next"); + await waitForCanvasValue(page, 1); + + const nextValue = getCanvasValue(page.url()); + const isPrevDisabledAfterNext = await page.$eval( + ".btn.imageBtn.prev", + (btn) => btn.disabled + ); + expect(isPrevDisabledAfterNext).toBe(false); + + await page.click(".btn.imageBtn.prev"); + await waitForCanvasValue(page, 0); + + const previousValue = getCanvasValue(page.url()); + const isPrevDisabledAgain = await page.$eval( + ".btn.imageBtn.prev", + (btn) => btn.disabled + ); + expect(isPrevDisabledAgain).toBe(true); + + expect(startValue).toBe(0); + expect(nextValue).toBe(1); + expect(previousValue).toBe(0); + }); + + // zoom in and zoom out + it("can zoom in and zoom out", async () => { + await page.waitForSelector(".zoomIn.viewportNavButton", { + visible: true, + }); + await page.waitForSelector(".zoomOut.viewportNavButton", { + visible: true, + }); + + const initialUrl = page.url(); + const initialXywh = getXywhValue(initialUrl); + + await page.$eval(".zoomIn.viewportNavButton", (el) => el.click()); + await page.waitForFunction( + (prev) => window.location.href !== prev, + {}, + initialUrl + ); + + const zoomInUrl = page.url(); + const zoomInXywh = getXywhValue(zoomInUrl); + + expect(zoomInXywh).not.toBeNull(); + expect(zoomInXywh).not.toBe(initialXywh); + expect(zoomInXywh).toMatch(/^-?\d+,-?\d+,\d+,\d+$/); + + await page.$eval(".zoomOut.viewportNavButton", (el) => el.click()); + await page.waitForFunction( + (prev) => window.location.href !== prev, + {}, + zoomInUrl + ); + + const zoomOutUrl = page.url(); + const zoomOutXywh = getXywhValue(zoomOutUrl); + + expect(zoomOutXywh).not.toBeNull(); + expect(zoomOutXywh).not.toBe(zoomInXywh); + expect(zoomOutXywh).toMatch(/^-?\d+,-?\d+,\d+,\d+$/); + }); + + // rotate image + it("can rotate image", async () => { + await page.waitForSelector(".rotate.viewportNavButton", { + visible: true, + }); + + const initialRot = await getRotationFromNavigator(); + + await page.$eval(".rotate.viewportNavButton", (el) => el.click()); + await waitForRotation(90); + + const rotatedRot = await getRotationFromNavigator(); + expect(initialRot).toBe(0); + expect(rotatedRot).toBe(90); + }); + + // open and close adjust image control + it("can open and close adjust image control", async () => { + const btn = "button.viewportNavButton.adjustImage"; + const overlay = "div.overlay.adjustImage"; + const heading = "div.overlay.adjustImage .content .heading"; + const closeBtn = ".btn.btn-default.close"; + + await page.waitForSelector(btn, { visible: true }); + await page.$eval(btn, (el) => el.click()); + + await page.waitForSelector(overlay, { visible: true }); + + const text = await page.$eval(heading, (el) => el.textContent.trim()); + expect(text).toBe("Adjust image"); + + await page.$eval(closeBtn, (el) => el.click()); + + const isOverlayVisible = await page.evaluate(() => { + const isOverlayVisible = document.querySelector( + "div.overlay.adjustImage" + ); + const style = window.getComputedStyle(isOverlayVisible); + + return ( + style.getPropertyValue("display") === "none" || + style.getPropertyValue("visibility") === "hidden" + ); + }); + expect(isOverlayVisible).toBe(false); + }); + }); + + describe("content panel", () => { + const contentExpandBtn = "button.expandButton"; + const contentCollapseBtn = "button.collapseButton"; + const contentTabs = "div.leftPanel"; + const contentIndexTab = ".index.tab"; + const contentIndexActiveTab = ".index.tab.on"; + const contentThumbnailsTab = ".thumbs.tab"; + const contentThumbnailsActiveTab = ".thumbs.tab.on"; + + beforeEach(async () => { + await page.goto(BASE_URL); + }); + + // switch content tabs and collapse content panel + it("can switch content tabs", async () => { + await page.waitForSelector(contentThumbnailsActiveTab, { visible: true }); + + await page.click(contentIndexTab); + await page.waitForSelector(contentIndexActiveTab, { visible: true }); + + expect( + await page.$eval(contentIndexTab, (el) => el.classList.contains("on")) + ).toBe(true); + + expect( + await page.$eval(contentThumbnailsTab, (el) => + el.classList.contains("on") + ) + ).toBe(false); + }); + + it("can collapse content", async () => { + await page.waitForSelector(contentTabs, { visible: true }); + await page.waitForSelector(contentCollapseBtn, { visible: true }); + + await page.click(contentCollapseBtn); + + await page.waitForSelector(contentExpandBtn, { visible: true }); + await page.waitForSelector(".leftPanel .tabs", { hidden: true }); + }); + }); + + describe("more information panel", () => { + const moreInfoExpandBtn = ".rightPanel button.expandButton"; + const moreInfoCollapseBtn = ".rightPanel button.collapseButton"; + const moreInfoHeader = ".rightPanel div.header"; + + beforeEach(async () => { + await page.goto(BASE_URL); + }); + + it("can expand and collapse moreInformation panel", async () => { + await page.waitForSelector(moreInfoExpandBtn, { visible: true }); + await page.click(moreInfoExpandBtn); + + // verify expanded state and header + await page.waitForSelector(moreInfoCollapseBtn, { visible: true }); + await page.waitForSelector(moreInfoHeader, { visible: true }); + + const headers = await page.$$eval(moreInfoHeader, (els) => + els.map((el) => el.textContent.trim()) + ); + + expect(headers).toContain("About the item"); + + await page.click(moreInfoCollapseBtn); + await page.waitForSelector(moreInfoExpandBtn, { visible: true }); + await page.waitForSelector(moreInfoHeader, { hidden: true }); + }); + }); + + // PDF MANIFEST TEST + describe("PDF manifest", () => { + beforeEach(async () => { + await page.goto(viewerUrl(PDF_MULTI_FILE_MANIFEST), { + waitUntil: "domcontentloaded", + }); + }); + + it("loads PDF manifest successfully", async () => { + expect(page.url()).toContain(encodeURIComponent(PDF_MULTI_FILE_MANIFEST)); + + await page.waitForSelector(".uv", { visible: true }); + + await page.waitForFunction(() => { + return document.querySelectorAll("iframe").length > 0; + }); + + const viewerFrame = page.frames().find((f) => { + const url = f.url(); + + return ( + url.includes("uv.html") || + url.includes("viewer") || + url.includes("manifest") + ); + }); + + expect(viewerFrame).toBeTruthy(); + + await viewerFrame.waitForSelector("canvas", { visible: true}); + + const canvasInfo = await viewerFrame.evaluate(() => { + const canvas = document.querySelector( + "canvas" + ); + + if (!canvas) return null; + return { + width: canvas.width, + height: canvas.height, + }; + }); + expect(canvasInfo).not.toBeNull(); + expect(canvasInfo.width).toBeGreaterThan(0); + expect(canvasInfo.height).toBeGreaterThan(0); + + const pageText = await viewerFrame.evaluate(() => document.body.innerText); + + expect(pageText).not.toContain("Unable to load"); + expect(pageText).not.toContain("Error loading"); + }); + + it("shows multiple PDF files in the sidebar and allows navigation", async () => { + await page.waitForSelector("button.expandButton", { visible: true }); + await page.click("button.expandButton"); + + await page.waitForSelector(".thumb", { visible: true }); + + const thumbs = await page.$$(".thumb"); + + expect(thumbs.length).toBeGreaterThan(1); + await thumbs[1].click(); + + await page.waitForFunction(() => window.location.href.includes("cv=1")); + + expect(page.url()).toContain("cv=1"); + }); + }); +}); \ No newline at end of file From ca85d54b1eb11743094f64aca371cc4cd3089dfb Mon Sep 17 00:00:00 2001 From: K8Sewell Date: Tue, 4 Aug 2026 11:35:35 -0500 Subject: [PATCH 9/9] Add back empty line --- __tests__/test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/test.js b/__tests__/test.js index 8c65f3873..1f050fcb1 100644 --- a/__tests__/test.js +++ b/__tests__/test.js @@ -483,4 +483,4 @@ describe("Universal Viewer", () => { expect(page.url()).toContain("cv=1"); }); }); -}); \ No newline at end of file +});