diff --git a/apps/playground/AGENTS.md b/apps/playground/AGENTS.md index 231572ed7..b0a06e79e 100644 --- a/apps/playground/AGENTS.md +++ b/apps/playground/AGENTS.md @@ -164,7 +164,8 @@ Share links come from `@opendatacapture/playground-url`, which lz-string-compres the URL fragment. A link that differs only in its fragment does not reload the page, so `IndexPage` reads the URL through `useLocationHref`, which re-renders on `hashchange`; reading `location.href` directly means pasting a second share link into an open tab does nothing. Its `$EditorFile` requires -`content` to be a UTF-8 string, so binary assets do not survive a share URL. +`content` to be a UTF-8 string, which binary assets satisfy because the store already holds them as +base64; the package's CLI leaves them out of the links it prints to keep them short. ## Odds and ends diff --git a/packages/playground-url/AGENTS.md b/packages/playground-url/AGENTS.md index b3c818288..76a95f0a2 100644 --- a/packages/playground-url/AGENTS.md +++ b/packages/playground-url/AGENTS.md @@ -16,10 +16,12 @@ its `publishConfig`, so a change here is a change to a public API, and `version` workspace. `pnpm build` runs `scripts/build.js`, which esbuilds `src/cli.ts` into `dist/cli.js` and nothing else — `dist` exists only for the `bin`. A library change needs no build; a CLI change does. -**A share URL can carry UTF-8 text and nothing else.** Files are `JSON.stringify`d and lz-string -compressed into the URL fragment, so images, audio and video cannot be represented at all. The CLI -skips them with a warning; `TEXT_FILE_EXT_REGEX` and `BINARY_FILE_EXT_REGEX` in `src/cli.ts` are the -allowlist and the known-skip list, and both must track what the playground editor accepts. +**A share URL carries every file as a UTF-8 string.** Files are `JSON.stringify`d and lz-string +compressed into the URL fragment. The playground holds images, audio and video as base64 strings, so +its Share button carries them too; the CLI skips them with a warning to keep links short. +`TEXT_FILE_EXT_REGEX` and `BINARY_FILE_EXT_REGEX` in `src/cli.ts` are the allowlist and the +known-skip list. They must track `inferFileType` and `isBase64EncodedFileType` in +`apps/playground/src/utils/file.ts`: an `.svg` is text there, so it is text here. The encoding is a wire format for links people have already shared. Changing what `encodeFiles` writes invalidates every existing link, so `decodeShareURL` has to keep reading the old shape. diff --git a/packages/playground-url/README.md b/packages/playground-url/README.md index aca5fd6cd..6dd4aaa7f 100644 --- a/packages/playground-url/README.md +++ b/packages/playground-url/README.md @@ -43,4 +43,4 @@ The status line is written to stderr and the link to stdout, so it pipes cleanly | `-b, --base-url ` | Playground origin to link to (defaults to the hosted playground). | | `-o, --open` | Open the generated link in your default browser. | -Only text source files (`.css`, `.html`, `.js`, `.jsx`, `.json`, `.ts`, `.tsx`) are embedded; binary assets cannot be represented in a share URL and are skipped with a warning. +Only text files (`.css`, `.html`, `.js`, `.jsx`, `.json`, `.svg`, `.ts`, `.tsx`) are embedded. Binary assets (`.jpeg`, `.jpg`, `.mp3`, `.mp4`, `.png`, `.webp`) are skipped with a warning to keep links short; the playground's own Share button does carry them, base64-encoded. diff --git a/packages/playground-url/src/__tests__/cli.test.ts b/packages/playground-url/src/__tests__/cli.test.ts index f9da71f46..012e83fdc 100644 --- a/packages/playground-url/src/__tests__/cli.test.ts +++ b/packages/playground-url/src/__tests__/cli.test.ts @@ -44,6 +44,21 @@ describe('cli', () => { expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping 'icon.png'")); }); + it('should embed an SVG as the text it is, rather than skip it as a binary asset', async () => { + const svg = '\n'; + fs.writeFileSync(path.join(tmpDir, 'index.ts'), "import logo from './logo.svg';\nexport default { logo };"); + fs.writeFileSync(path.join(tmpDir, 'logo.svg'), svg); + process.argv = ['node', 'cli.js', tmpDir]; + const { decodeShareURL } = await import('../share-url.js'); + await import('../cli.js'); + const href = stdoutSpy.mock.calls + .map(([chunk]: [unknown]) => chunk) + .find((chunk: unknown) => typeof chunk === 'string' && chunk.includes('http')); + const decoded = decodeShareURL(new URL((href as string).trim())); + expect(decoded?.files.find((file) => file.name === 'logo.svg')?.content).toBe(svg); + expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining('Skipping')); + }); + it('should ignore a subdirectory rather than read it as a file', async () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); fs.mkdirSync(path.join(tmpDir, 'nested')); diff --git a/packages/playground-url/src/cli.ts b/packages/playground-url/src/cli.ts index b810b4d27..b0262ea6c 100644 --- a/packages/playground-url/src/cli.ts +++ b/packages/playground-url/src/cli.ts @@ -10,10 +10,10 @@ import { DEFAULT_PLAYGROUND_URL, encodeShareURL } from './share-url.js'; import type { EditorFile } from './models.js'; -/** Text files the playground can load from a share URL. */ -const TEXT_FILE_EXT_REGEX = /\.(css|html|js|jsx|json|ts|tsx)$/i; -/** Bundler assets that exist on disk but cannot be embedded in a (string-only) share URL. */ -const BINARY_FILE_EXT_REGEX = /\.(jpeg|jpg|mp3|mp4|png|svg|webp)$/i; +/** Text files embedded in the share URL as they are on disk, as the playground holds them. */ +const TEXT_FILE_EXT_REGEX = /\.(css|html|js|jsx|json|svg|ts|tsx)$/i; +/** Binary assets the CLI leaves out to keep links short, although the playground could load them. */ +const BINARY_FILE_EXT_REGEX = /\.(jpeg|jpg|mp3|mp4|png|webp)$/i; function parseTarget(target: string): string { const resolved = path.resolve(target); @@ -46,7 +46,7 @@ function readInstrumentFiles(target: string): EditorFile[] { files.push({ content: fs.readFileSync(filepath, 'utf-8'), name: entry.name }); } else if (BINARY_FILE_EXT_REGEX.test(entry.name)) { process.stderr.write( - chalk.yellow(`⚠ Skipping '${entry.name}': binary assets cannot be embedded in a share URL\n`) + chalk.yellow(`⚠ Skipping '${entry.name}': the CLI does not embed binary assets in a share URL\n`) ); } } diff --git a/packages/playground-url/src/models.ts b/packages/playground-url/src/models.ts index 08d382af9..b84fb4938 100644 --- a/packages/playground-url/src/models.ts +++ b/packages/playground-url/src/models.ts @@ -2,8 +2,8 @@ import { z } from 'zod/v4'; /** * A single source file of a playground instrument. The content must be a UTF-8 - * string, mirroring the playground editor's own file model — binary assets - * (images, audio, video) cannot be represented in a share URL. + * string, mirroring the playground editor's own file model: text files as they + * are, and binary assets (images, audio, video) as base64. */ export type EditorFile = z.infer; export const $EditorFile = z.object({ diff --git a/testing/src/specs/playground.spec.ts b/testing/src/specs/playground.spec.ts index a4610baef..c23c60b52 100644 --- a/testing/src/specs/playground.spec.ts +++ b/testing/src/specs/playground.spec.ts @@ -1,3 +1,6 @@ +import { execFileSync } from 'node:child_process'; +import * as path from 'node:path'; + import { generatePlaygroundURL } from '@opendatacapture/playground-url'; import { PlaygroundPage } from '../pages/playground/index.page'; @@ -247,6 +250,33 @@ test.describe('playground', () => { await expect(task.getByRole('heading', { name: 'Vite + React' })).toBeVisible({ timeout: PREVIEW_TIMEOUT }); }); + // `test:e2e` builds `@opendatacapture/playground-url` first, so its CLI is in `dist`. The React + // example imports `react.svg`; the CLI used to skip it as binary, so the link failed to compile. + test('should render an SVG asset from a link generated by the playground-url CLI', async ({ page }) => { + const repoRoot = path.resolve(import.meta.dirname, '../../..'); + const href = execFileSync( + process.execPath, + [ + path.join(repoRoot, 'packages/playground-url/dist/cli.js'), + path.join(repoRoot, 'apps/playground/src/instruments/examples/interactive/Interactive-With-React'), + '--base-url', + playgroundURL + ], + { encoding: 'utf-8' } + ).trim(); + const playground = new PlaygroundPage(page); + await playground.goto(href); + + await playground.preview.getByRole('button', { name: 'Begin' }).click({ timeout: PREVIEW_TIMEOUT }); + + // A visibility check would also pass for an SVG that arrives base64-encoded and renders broken. + const logo = playground.preview + .frameLocator('iframe[name="interactive-instrument"]') + .getByRole('img', { name: 'React logo' }); + await expect(logo).toBeVisible({ timeout: PREVIEW_TIMEOUT }); + await expect.poll(() => logo.evaluate((img: HTMLImageElement) => img.naturalWidth)).toBeGreaterThan(0); + }); + test('should hand a submission from the preview back to the editor', async ({ page }) => { const playground = new PlaygroundPage(page); await playground.goto();