diff --git a/packages/instrument-bundler/src/__tests__/utils.test.ts b/packages/instrument-bundler/src/__tests__/utils.test.ts index b0969a865..33fb57d4a 100644 --- a/packages/instrument-bundler/src/__tests__/utils.test.ts +++ b/packages/instrument-bundler/src/__tests__/utils.test.ts @@ -22,6 +22,10 @@ describe('extractInputFileExtension', () => { it('should return null for filenames without extensions', () => { expect(extractInputFileExtension('filename')).toBeNull(); }); + + it('should lower-case the extension, so an upper-case match is still a BundlerInputFileExtension', () => { + expect(extractInputFileExtension('a.PNG')).toBe('.png'); + }); }); describe('inferLoader', () => { @@ -61,6 +65,10 @@ describe('inferLoader', () => { expect(inferLoader('image.webp')).toBe('dataurl'); }); + it('should infer the loader from an upper-case extension, so camera files like IMG_0001.JPG bundle like .jpg', () => { + expect(inferLoader('IMG_0001.JPG')).toBe('dataurl'); + }); + it('should throw an error for unsupported extensions', () => { expect(() => inferLoader('document.pdf')).toThrowError( 'Cannot infer loader due to unexpected extension for filename: document.pdf' diff --git a/packages/instrument-bundler/src/utils.ts b/packages/instrument-bundler/src/utils.ts index 639a197e4..bdfa1fc33 100644 --- a/packages/instrument-bundler/src/utils.ts +++ b/packages/instrument-bundler/src/utils.ts @@ -9,7 +9,7 @@ export const BUNDLER_FILE_EXT_REGEX = /\.(css|html|jpeg|jpg|js|jsx|json|mp3|mp4| export function extractInputFileExtension(filename: string) { const ext = BUNDLER_FILE_EXT_REGEX.exec(filename)?.[0]; - return (ext ?? null) as BundlerInputFileExtension | null; + return (ext?.toLowerCase() ?? null) as BundlerInputFileExtension | null; } export function inferLoader(filename: string): Loader { diff --git a/testing/src/specs/playground.spec.ts b/testing/src/specs/playground.spec.ts index 0a143a0d3..332ef22ad 100644 --- a/testing/src/specs/playground.spec.ts +++ b/testing/src/specs/playground.spec.ts @@ -96,6 +96,43 @@ export default defineInstrument({ `; } +/** 1×1 PNG. The playground stores binary assets as base64 and decodes them before bundling. */ +const STIM_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +/** + * An interactive instrument that paints an imported PNG. The extension stays upper case: that is + * the filename shape the playground bundles when it renders this preview. + */ +function uppercasePngInstrumentSource(title: string): string { + return ` +import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; +import { z } from '/runtime/v1/zod@3.x'; + +import stim from './stim.PNG'; + +export default defineInstrument({ + kind: 'INTERACTIVE', + language: 'en', + tags: ['Stimulus'], + internal: { edition: 1, name: 'UPPERCASE_PNG' }, + clientDetails: { estimatedDuration: 1, instructions: ['Look at the image'] }, + content: { + render() { + const image = document.createElement('img'); + image.alt = 'Stimulus'; + image.src = stim; + image.width = 32; + image.height = 32; + document.body.appendChild(image); + } + }, + details: { description: 'Upper-case PNG import', license: 'Apache-2.0', title: '${title}' }, + measures: {}, + validationSchema: z.object({}) +}); +`; +} + // The first paint waits on the 13 MB esbuild download and the toolchain boot; the preview then // compiles on a 2 s poll, so the whole chain is slower than the suite's default expect timeout. const PREVIEW_TIMEOUT = 60_000; @@ -148,6 +185,33 @@ test.describe('playground', () => { await expect(page.locator('body')).not.toHaveAttribute('data-probe', /./); }); + test('should render a share link that imports an upper-case PNG, so the preview shows the stimulus', async ({ + page, + uniqueId + }) => { + const title = `Upper PNG ${uniqueId}`; + const playground = new PlaygroundPage(page); + await playground.goto( + generatePlaygroundURL({ + baseURL: playgroundURL, + files: [ + { content: uppercasePngInstrumentSource(title), name: 'index.ts' }, + { content: STIM_PNG_BASE64, name: 'stim.PNG' } + ], + label: title + }) + ); + + await expect(playground.preview.getByRole('heading', { name: title })).toBeVisible({ timeout: PREVIEW_TIMEOUT }); + await playground.preview.getByRole('button', { name: 'Begin' }).click(); + + const stimulus = playground.preview + .frameLocator('iframe[name="interactive-instrument"]') + .getByRole('img', { name: 'Stimulus' }); + await expect(stimulus).toBeVisible({ timeout: PREVIEW_TIMEOUT }); + await expect(stimulus).toHaveAttribute('src', `data:image/png;base64,${STIM_PNG_BASE64}`); + }); + // An interactive instrument renders in a frame of its own inside the preview, which reads its // parent's document. That only works because the preview is a real origin rather than an opaque // sandbox, which is the reason the preview is served from a second host.