Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/instrument-bundler/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion packages/instrument-bundler/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
64 changes: 64 additions & 0 deletions testing/src/specs/playground.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down