diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 830ce597..0c1b7c67 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,133 +2,58 @@ ## Project Overview -A browser DOM library that replaces elements (with `data-src` or `src` attributes pointing to SVG files) with inline SVG markup via XMLHttpRequest. Published as `@tanem/svg-injector` to npm. +A browser DOM library that replaces elements (with `data-src` or `src` attributes pointing to SVG files or data URLs) with inline SVG markup. Published as `@tanem/svg-injector` to npm. -## Architecture +## Known Limitations -The injection pipeline flows through these modules in `src/`: +These constraints are not expressed in the source code and affect how features should be used or extended. -1. **`svg-injector.ts`**: public API entry point. Accepts single element or element collections, iterates and delegates to `injectElement`. -2. **`inject-element.ts`**: core orchestrator. Loads SVG (cached or uncached), transfers attributes from the original element to the SVG, renumerates IRI elements for uniqueness, handles script eval, calls `beforeEach` hook, then replaces the DOM element via `parentNode.replaceChild`. -3. **`load-svg-cached.ts`** / **`load-svg-uncached.ts`**: two loading strategies. Cached path uses `cache.ts` (a `Map`) and `request-queue.ts` to deduplicate concurrent requests for the same URL. -4. **`make-ajax-request.ts`**: XHR wrapper that validates content-type (`image/svg+xml` or `text/plain`) and handles local file protocol detection via `is-local.ts`. -5. **`unique-id.ts`**: simple incrementing counter used to make IRI element IDs unique across multiple injected instances of the same SVG. - -Key design decisions: - -- SVG cloning (`clone-svg.ts`) uses `cloneNode(true)` to avoid mutating cached originals. -- Request queue callbacks are fired via `setTimeout(fn, 0)` to avoid blocking the renderer. -- The `injectedElements` array in `inject-element.ts` is module-level state that prevents duplicate injection of the same element. - -## SVG Sprite Support - -When the `data-src` or `src` URL contains a fragment identifier (e.g. `sprite.svg#icon-name`), the injector fetches the base URL, extracts the `` with the matching ID, and converts it to a standalone `` for injection. - -Key design decisions: - -- The fragment is stripped before passing the URL to the loader, so the cache keys by base URL. All symbols from the same sprite share one cache entry and one XHR request. -- Extraction operates on the cloned sprite (from `clone-svg.ts`), never mutating the cached original. - -**Known limitations:** +### SVG Sprites - **Self-contained symbols only.** Shared root-level `` (gradients, filters, clip paths referenced by multiple symbols) are not resolved into the extracted SVG. Symbols must contain all their own definitions or use individual SVG files. - **Only `` elements are supported.** The fragment ID must match the `id` attribute of a `` element in the sprite. Other element types (e.g. ``, ``) are not extracted. - **`` chains within symbols are not resolved.** If a symbol internally references another symbol via ``, the reference will break after extraction. -## Build Pipeline +### Data URLs -``` -npm run build → clean → compile (tsc) → bundle (rollup) -``` +- **Only `data:image/svg+xml` MIME types are supported.** Other image data URLs (e.g. `data:image/png`) are not handled and will fall through to XHR (which will fail). +- **`DOMParser` error detection is best-effort.** Browsers embed a `` element in the returned document on invalid input; the library checks for this but the error message format varies by browser. -- **`compile`**: TypeScript compiles `src/` → `compiled/` using `tsconfig.base.json` (target: ES5, module: ESNext). -- **`bundle`**: Rollup produces 5 outputs from `compiled/index.js`: CJS dev/prod, ESM, UMD dev/prod. See `rollup.config.mjs`. -- **`postbundle`**: Copies `index.js` (CJS env-switcher) into `dist/`. -- The root `index.js` is a CJS entry that selects dev/prod bundle based on `NODE_ENV`. +### IRI Renumeration -## Testing - -Tests run in real browsers via **Playwright** (`@playwright/test`). Each test file uses Playwright's `test` / `test.describe` / `expect` APIs directly. - -```bash -npm test # Full pipeline: check:types → lint → build → test:playwright -npm run test:playwright # Run only browser tests (requires prior build) -npm run test:coverage # Full pipeline with coverage: build:coverage → test → report -``` - -Key testing patterns: - -- **`test/playwright/test-utils.ts`** provides `setupPage()` (creates a route-intercepted page serving a base HTML document and SVG fixtures), `injectSvg()` (injects SVG via the UMD bundle inside the page and returns serialised HTML + callback data), `addSvgInjector()` (adds the UMD bundle as an init script), and `formatHtml()` (normalises whitespace). -- Tests use `page.route()` to intercept fixture requests and serve files from `test/fixtures/` with configurable status codes, content types, and bodies. No dev server is needed. -- Tests compare serialised HTML strings and branch on `browserName` (firefox vs others) because browsers serialise SVG attributes in different orders. IE-specific branches have been removed. -- SVG fixtures live in `test/fixtures/`. -- Each test gets a fresh browser context automatically (Playwright's default isolation), so there is no manual cache/queue cleanup needed between tests. -- **`test/playwright/coverage.ts`** is imported as a side-effect in every test file (`import './playwright/coverage'`). It collects `window.__coverage__` after each test (when `COVERAGE=1`) and writes per-test JSON files to `.nyc_output/`. -- **Coverage**: Instrumented via `babel-plugin-istanbul` in the Rollup build (enabled when `COVERAGE=1`). After tests, `scripts/coverage-report.js` merges the per-test coverage JSON files, remaps through source maps, filters to `src/` only (excluding `src/index.ts` and `src/types.ts`), and outputs lcov to `coverage/`. No explicit threshold is enforced; coverage is uploaded to Codecov in CI. -- **`playwright.config.ts`** defines three projects: chromium, firefox, webkit. CI uses `retries: 1` and `workers: 2`. +- **All matching element types are renumerated, not just those inside ``.** If an SVG has `` outside `` (e.g. a US map), that ID will be rewritten. Users who need to query injected elements by their original IDs should set `renumerateIRIElements: false`. See [#14 (comment)](https://github.com/tanem/svg-injector/issues/14#issuecomment-457270023). +- **String references in `
+ + +
+``` + +```js +import { SVGInjector } from '@tanem/svg-injector' + +SVGInjector(document.querySelectorAll('[data-src]')) +``` + +The library detects the `data:image/svg+xml` prefix and parses the SVG content directly using `DOMParser`. No XHR is made, which avoids Content Security Policy violations that would otherwise occur when attempting to fetch a `data:` URI. + +## Supported formats + +- `data:image/svg+xml,` followed by URL-encoded SVG (percent-encoded). +- `data:image/svg+xml;base64,` followed by base64-encoded SVG. +- `data:image/svg+xml;charset=utf-8,` followed by URL-encoded SVG. + +## Fragment identifiers + +Fragment identifiers work with data URLs the same way as with regular URLs. If a data URL contains an inlined SVG sprite, you can extract a specific symbol: + +```html +
+``` + +## Caching + +Data URLs bypass the request cache entirely since the SVG content is already embedded in the URL. The `cacheRequests` option has no effect on data URL elements. + +## Limitations + +- Only `data:image/svg+xml` MIME types are supported. Other image formats (e.g. `data:image/png`) are not handled. +- Parse errors from malformed SVG content are reported through the `afterEach` error callback. diff --git a/examples/data-url-usage/index.html b/examples/data-url-usage/index.html new file mode 100644 index 00000000..268835bf --- /dev/null +++ b/examples/data-url-usage/index.html @@ -0,0 +1,43 @@ + + + + + SVGInjector Data URL Usage Example + + + +

SVG Data URL Injection

+

+ These icons use data:image/svg+xml URLs in their + data-src attributes, as a bundler like Vite would produce. + The SVG content is parsed directly from the data URL without making a + network request. +

+

URL-encoded

+
+

Base64-encoded

+
+ + + diff --git a/examples/data-url-usage/index.ts b/examples/data-url-usage/index.ts new file mode 100644 index 00000000..dcc141fb --- /dev/null +++ b/examples/data-url-usage/index.ts @@ -0,0 +1,3 @@ +import { SVGInjector } from '@tanem/svg-injector' + +SVGInjector(document.getElementsByClassName('inject-me')) diff --git a/examples/data-url-usage/package.json b/examples/data-url-usage/package.json new file mode 100644 index 00000000..e781abe3 --- /dev/null +++ b/examples/data-url-usage/package.json @@ -0,0 +1,21 @@ +{ + "name": "data-url-usage", + "version": "1.0.0", + "description": "SVGInjector Data URL Usage Example", + "scripts": { + "build": "parcel build index.html", + "start": "parcel index.html --open" + }, + "dependencies": { + "@tanem/svg-injector": "latest" + }, + "overrides": { + "braces": "3.0.3" + }, + "keywords": [ + "@tanem/svg-injector" + ], + "devDependencies": { + "parcel": "2.16.4" + } +} diff --git a/examples/data-url-usage/tsconfig.json b/examples/data-url-usage/tsconfig.json new file mode 100644 index 00000000..60001481 --- /dev/null +++ b/examples/data-url-usage/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "module": "esnext", + "target": "es2020", + "jsx": "preserve", + "esModuleInterop": true, + "sourceMap": true, + "allowJs": true, + "lib": [ + "es2020", + "dom" + ], + "rootDir": ".", + "moduleResolution": "bundler" + }, + "files": ["index.ts"] +} diff --git a/src/inject-element.ts b/src/inject-element.ts index 81e77d43..e3bf090e 100644 --- a/src/inject-element.ts +++ b/src/inject-element.ts @@ -1,6 +1,7 @@ import extractSymbol from './extract-symbol' import loadSvgCached from './load-svg-cached' import loadSvgUncached from './load-svg-uncached' +import parseDataUrl from './parse-data-url' import type { BeforeEach, Errback, EvalScripts } from './types' import uniqueId from './unique-id' @@ -49,9 +50,18 @@ const injectElement = ( const baseUrl = hashIndex !== -1 ? elUrl.slice(0, hashIndex) : elUrl const symbolId = hashIndex !== -1 ? elUrl.slice(hashIndex + 1) : null - const loadSvg = cacheRequests ? loadSvgCached : loadSvgUncached + // Data URLs already contain the SVG content, so parse them directly instead + // of making a pointless XHR. This avoids CSP violations that occur when + // browsers (or bundlers like Vite) inline SVGs as data URIs. + const dataUrlResult = parseDataUrl(baseUrl) + if (dataUrlResult instanceof Error) { + injectedElements.splice(injectedElements.indexOf(el), 1) + ;(el as ElementType) = null + callback(dataUrlResult) + return + } - loadSvg(baseUrl, httpRequestWithCredentials, (error, loadedSvg) => { + const handleLoadedSvg = (error: Error | null, loadedSvg?: SVGSVGElement) => { if (!loadedSvg) { injectedElements.splice(injectedElements.indexOf(el), 1) ;(el as ElementType) = null @@ -367,7 +377,21 @@ const injectElement = ( ;(el as ElementType) = null callback(null, svg) - }) + } + + if (dataUrlResult) { + // Use setTimeout to match the async behaviour of the XHR path. Callers may + // depend on injection being asynchronous (e.g. reading DOM state after + // calling SVGInjector but before the callback fires). + setTimeout(() => { + handleLoadedSvg(null, dataUrlResult) + }, 0) + return + } + + const loadSvg = cacheRequests ? loadSvgCached : loadSvgUncached + + loadSvg(baseUrl, httpRequestWithCredentials, handleLoadedSvg) } export default injectElement diff --git a/src/parse-data-url.ts b/src/parse-data-url.ts new file mode 100644 index 00000000..ef57e375 --- /dev/null +++ b/src/parse-data-url.ts @@ -0,0 +1,56 @@ +const dataUrlPrefix = 'data:image/svg+xml' + +// Parses an SVG data URL (URL-encoded or base64) into an SVGSVGElement without +// making a network request. Returns null for non-data-URL strings so callers +// can fall through to XHR loading. +const parseDataUrl = (url: string): SVGSVGElement | Error | null => { + if (!url.startsWith(dataUrlPrefix)) { + return null + } + + const rest = url.slice(dataUrlPrefix.length) + + let svgString: string + + if (rest.startsWith(';base64,')) { + try { + svgString = atob(rest.slice(';base64,'.length)) + } catch { + return new Error('Invalid base64 in data URL') + } + } else if (rest.startsWith(',')) { + try { + svgString = decodeURIComponent(rest.slice(','.length)) + } catch { + return new Error('Invalid encoding in data URL') + } + } else if (rest.startsWith(';charset=utf-8,')) { + // Some tools emit an explicit charset parameter before the comma. + try { + svgString = decodeURIComponent(rest.slice(';charset=utf-8,'.length)) + } catch { + return new Error('Invalid encoding in data URL') + } + } else { + return new Error('Unsupported data URL format') + } + + const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml') + + // DOMParser returns a document with a element on invalid input + // rather than throwing. + const parserError = doc.querySelector('parsererror') + if (parserError) { + return new Error( + 'Data URL SVG parse error: ' + parserError.textContent.trim(), + ) + } + + if (!(doc.documentElement instanceof SVGSVGElement)) { + return new Error('Data URL did not contain a valid SVG element') + } + + return doc.documentElement +} + +export default parseDataUrl diff --git a/test/data-url.test.ts b/test/data-url.test.ts new file mode 100644 index 00000000..bdc57ed1 --- /dev/null +++ b/test/data-url.test.ts @@ -0,0 +1,362 @@ +import { expect, test } from './playwright/coverage' +import { formatHtml, injectSvg, setupPage } from './playwright/test-utils' + +const thumbUpPath = + 'M4.47 0c-.19.02-.37.15-.47.34-.13.26-1.09 2.19-1.28 2.38-.19.19-.44.28-.72.28v4h3.5c.21 0 .39-.13.47-.31 0 0 1.03-2.91 1.03-3.19 0-.28-.22-.5-.5-.5h-1.5c-.28 0-.5-.25-.5-.5s.39-1.58.47-1.84c.08-.26-.05-.54-.31-.63-.07-.02-.12-.04-.19-.03zm-4.47 3v4h1v-4h-1z' +const thumbUpPathElement = `` + +const thumbUpSvgRaw = + '' + +const spriteRaw = + '' + +// URL-encoded data URL. Vite produces this format for SVGs without . +const encodedDataUrl = 'data:image/svg+xml,' + encodeURIComponent(thumbUpSvgRaw) + +// Base64-encoded data URL. Vite produces this format for SVGs containing +// . +const base64DataUrl = + 'data:image/svg+xml;base64,' + + Buffer.from(thumbUpSvgRaw, 'utf8').toString('base64') + +// URL-encoded with explicit charset parameter (some tools emit this). +const charsetDataUrl = + 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(thumbUpSvgRaw) + +// Sprite as a URL-encoded data URL with a fragment identifier. +const spriteDataUrl = + 'data:image/svg+xml,' + encodeURIComponent(spriteRaw) + '#icon-thumb-up' + +test.describe('data URL support', () => { + test('URL-encoded data URL', async ({ page }) => { + await setupPage(page) + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + const actual = formatHtml(result.html) + const expected = `${thumbUpPathElement}` + + expect(actual).toBe(expected) + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('base64-encoded data URL', async ({ page }) => { + await setupPage(page) + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + const actual = formatHtml(result.html) + const expected = `${thumbUpPathElement}` + + expect(actual).toBe(expected) + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('data URL with explicit charset', async ({ page }) => { + await setupPage(page) + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + const actual = formatHtml(result.html) + const expected = `${thumbUpPathElement}` + + expect(actual).toBe(expected) + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('multiple data URL elements', async ({ page }) => { + await setupPage(page) + + const result = await injectSvg(page, { + html: ` +
+
+ `, + selector: '.inject-me', + selectorAll: true, + }) + + const actualFirst = formatHtml(result.afterEachCalls[0]!.svg ?? '') + const actualSecond = formatHtml(result.afterEachCalls[1]!.svg ?? '') + const expectedEncoded = `${thumbUpPathElement}` + const expectedBase64 = `${thumbUpPathElement}` + + expect(result.afterEachCalls).toHaveLength(2) + expect(result.afterEachCalls[0]!.error).toBe(null) + expect(actualFirst).toBe(expectedEncoded) + expect(result.afterEachCalls[1]!.error).toBe(null) + expect(actualSecond).toBe(expectedBase64) + expect(result.elementsLoaded).toBe(2) + }) + + test('data URL with fragment identifier (sprite)', async ({ page }) => { + await setupPage(page) + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + const actual = formatHtml(result.html) + const expected = `${thumbUpPathElement}` + + expect(actual).toBe(expected) + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('data URL with nonexistent symbol', async ({ page }) => { + await setupPage(page) + + const spriteDataUrlBadSymbol = + 'data:image/svg+xml,' + encodeURIComponent(spriteRaw) + '#nonexistent' + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toContain( + 'Symbol "nonexistent" not found', + ) + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('invalid SVG content in data URL', async ({ page }) => { + await setupPage(page) + + const badDataUrl = 'data:image/svg+xml,' + encodeURIComponent('') + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toContain( + 'Data URL did not contain a valid SVG element', + ) + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('malformed base64 data URL', async ({ page }) => { + await setupPage(page) + + const badBase64Url = 'data:image/svg+xml;base64,!!!not-base64!!!' + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe('Invalid base64 in data URL') + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('unsupported data URL format', async ({ page }) => { + await setupPage(page) + + const badFormatUrl = 'data:image/svg+xml;charset=iso-8859-1,' + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toContain( + 'Unsupported data URL format', + ) + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('attributes transferred from data URL element', async ({ page }) => { + await setupPage(page) + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '#my-svg', + }) + + const actual = formatHtml(result.html) + const expected = `${thumbUpPathElement}` + + expect(actual).toBe(expected) + }) + + test('data URL with cacheRequests disabled', async ({ page }) => { + await setupPage(page) + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + options: { cacheRequests: false }, + }) + + const actual = formatHtml(result.html) + const expected = `${thumbUpPathElement}` + + expect(actual).toBe(expected) + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('invalid percent-encoding in URL-encoded data URL', async ({ page }) => { + await setupPage(page) + + // %ZZ is not a valid percent-encoded sequence, so decodeURIComponent will + // throw. + const badEncodingUrl = 'data:image/svg+xml,%ZZ' + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toContain( + 'Invalid encoding in data URL', + ) + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('invalid percent-encoding in charset data URL', async ({ page }) => { + await setupPage(page) + + const badCharsetUrl = 'data:image/svg+xml;charset=utf-8,%ZZ' + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toContain( + 'Invalid encoding in data URL', + ) + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) + + test('malformed XML in data URL triggers parse error', async ({ page }) => { + await setupPage(page) + + // Unclosed tag produces a DOMParser parsererror, unlike which + // parses successfully but fails the SVGSVGElement check. + const malformedXmlUrl = 'data:image/svg+xml,' + encodeURIComponent('<') + + const result = await injectSvg(page, { + html: ` +
+ `, + selector: '.inject-me', + }) + + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toContain( + 'Data URL SVG parse error', + ) + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) + }) +}) diff --git a/test/local.test.ts b/test/local.test.ts index dab68626..eaaad379 100644 --- a/test/local.test.ts +++ b/test/local.test.ts @@ -35,9 +35,11 @@ test.describe('local', () => { }) expect(result.elementsLoaded).toBe(1) - expect(result.afterEachCalls[0]?.error).toBe( + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe( 'Note: SVG injection ajax calls do not work locally without adjusting security settings in your browser. Or consider using a local webserver.', ) + expect(result.afterEachCalls[0]!.svg).toBe(null) }) // Playwright browsers enforce cross-origin restrictions on file:// pages, so diff --git a/test/no-extension.test.ts b/test/no-extension.test.ts index 2930563a..a1b1acb6 100644 --- a/test/no-extension.test.ts +++ b/test/no-extension.test.ts @@ -20,7 +20,12 @@ test.describe('no extension', () => { // no error is returned. Chromium and Firefox surface the missing header. const expectedError = browserName === 'webkit' ? null : 'Content type not found' - expect(result.afterEachCalls[0]?.error ?? null).toBe(expectedError) + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error ?? null).toBe(expectedError) + if (browserName !== 'webkit') { + expect(result.afterEachCalls[0]!.svg).toBe(null) + } + expect(result.elementsLoaded).toBe(1) }) test('invalid media type', async ({ page }) => { @@ -36,7 +41,10 @@ test.describe('no extension', () => { selector: '.inject-me', }) - expect(result.afterEachCalls[0]?.error).toBe('invalid media type') + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe('invalid media type') + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) }) test('invalid content type', async ({ page }) => { @@ -52,9 +60,12 @@ test.describe('no extension', () => { selector: '.inject-me', }) - expect(result.afterEachCalls[0]?.error).toBe( + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe( 'Invalid content type: text/html', ) + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.elementsLoaded).toBe(1) }) test('image/svg+xml', async ({ page }) => { diff --git a/test/sprite.test.ts b/test/sprite.test.ts index d99deb6c..dd39cc4a 100644 --- a/test/sprite.test.ts +++ b/test/sprite.test.ts @@ -106,6 +106,7 @@ test.describe('sprite support', () => { expect(result.afterEachCalls[0]!.error).toBe( 'Symbol "nonexistent" not found in /fixtures/sprite.svg', ) + expect(result.afterEachCalls[0]!.svg).toBe(null) expect(result.elementsLoaded).toBe(1) }) @@ -171,7 +172,10 @@ test.describe('sprite support', () => { }) expect(result.afterEachCalls).toHaveLength(1) - expect(result.afterEachCalls[0]!.error).toBeTruthy() + expect(result.afterEachCalls[0]!.error).toBe( + 'Unable to load SVG file: /fixtures/nonexistent-sprite.svg', + ) + expect(result.afterEachCalls[0]!.svg).toBe(null) expect(result.elementsLoaded).toBe(1) }) }) diff --git a/test/svg-injector.test.ts b/test/svg-injector.test.ts index 58b42258..66020db8 100644 --- a/test/svg-injector.test.ts +++ b/test/svg-injector.test.ts @@ -77,27 +77,49 @@ test.describe('SVGInjector', () => { expect(result.elementsLoaded).toBe(0) expect(result.afterEachCalls).toHaveLength(0) + expect(result.html).toBe('') }) test('injection in progress', async ({ page }) => { await setupPage(page) - await page.evaluate(() => { - document.body.innerHTML = '' - const container = document.createElement('div') - container.innerHTML = ` -
- ` - document.body.appendChild(container) - const { SVGInjector } = (window as unknown as SvgInjectorWindow) - .SVGInjector - const element = container.querySelector('.inject-me') - SVGInjector(element) - SVGInjector(element) + const result = await page.evaluate(() => { + return new Promise<{ + html: string + afterEachCallCount: number + }>((resolve) => { + document.body.innerHTML = '' + const container = document.createElement('div') + container.innerHTML = ` +
+ ` + document.body.appendChild(container) + + let afterEachCallCount = 0 + const { SVGInjector } = (window as unknown as SvgInjectorWindow) + .SVGInjector + const element = container.querySelector('.inject-me') + SVGInjector(element, { + afterEach: () => { + afterEachCallCount++ + }, + afterAll: () => { + resolve({ + html: container.innerHTML, + afterEachCallCount, + }) + }, + }) + SVGInjector(element) + }) }) + + const actual = formatHtml(result.html) + expect(actual).toBe(thumbUpSvg) + expect(result.afterEachCallCount).toBe(1) }) test('attributes', async ({ page }) => { @@ -175,6 +197,12 @@ test.describe('SVGInjector', () => { test('cached success', async ({ page }) => { await setupPage(page) + let requestCount = 0 + await page.route('**/fixtures/thumb-up.svg', async (route) => { + requestCount++ + await route.fallback() + }) + const result = await page.evaluate(() => { return new Promise<{ html: string @@ -252,6 +280,7 @@ test.describe('SVGInjector', () => { expect(formatHtml(result.afterEachCalls[1]!.svg ?? '')).toBe( formatHtml(result.containerTwoHtml), ) + expect(requestCount).toBe(1) }) test('uncached requests fetch fresh data', async ({ page }) => { @@ -317,52 +346,70 @@ test.describe('SVGInjector', () => { test('errors should not be cached', async ({ page }) => { await setupPage(page) + let requestCount = 0 + await page.unroute('**/fixtures/**') + await page.route('**/fixtures/flaky.svg', async (route) => { + requestCount += 1 + if (requestCount === 1) { + await route.fulfill({ status: 404, body: '' }) + } else { + await route.fulfill({ + status: 200, + body: '', + headers: { 'content-type': 'image/svg+xml' }, + }) + } + }) + const result = await page.evaluate(() => { - return new Promise<{ errors: Array }>((resolve) => { + return new Promise<{ + afterEachCalls: Array<{ error: string | null; svg: string | null }> + }>((resolve) => { document.body.innerHTML = '' - const errors: Array = [] + const afterEachCalls: Array<{ + error: string | null + svg: string | null + }> = [] const { SVGInjector } = (window as unknown as SvgInjectorWindow) .SVGInjector - const inject = (html: string, done: () => void) => { + const inject = (done: () => void) => { const container = document.createElement('div') - container.innerHTML = html + container.innerHTML = ` +
+ ` document.body.appendChild(container) SVGInjector(container.querySelector('.inject-me'), { - afterEach: (error: Error | null) => { - errors.push(error ? error.message : null) + afterEach: (error: Error | null, svg?: Element | null) => { + afterEachCalls.push({ + error: error ? error.message : null, + svg: svg + ? svg.outerHTML || new XMLSerializer().serializeToString(svg) + : null, + }) }, afterAll: () => done(), }) } - inject( - ` -
- `, - () => { - inject( - ` -
- `, - () => resolve({ errors }), - ) - }, - ) + inject(() => { + inject(() => resolve({ afterEachCalls })) + }) }) }) - expect(result.errors).toHaveLength(2) - expect(result.errors[1]).toBe( - 'Unable to load SVG file: /fixtures/still-not-found.svg', + expect(requestCount).toBe(2) + expect(result.afterEachCalls).toHaveLength(2) + expect(result.afterEachCalls[0]!.error).toBe( + 'Unable to load SVG file: /fixtures/flaky.svg', ) + expect(result.afterEachCalls[0]!.svg).toBe(null) + expect(result.afterEachCalls[1]!.error).toBe(null) + expect(result.afterEachCalls[1]!.svg).toContain('viewBox="0 0 10 10"') }) test('svg not found error', async ({ page }) => { @@ -379,9 +426,11 @@ test.describe('SVGInjector', () => { }) expect(result.elementsLoaded).toBe(1) - expect(result.afterEachCalls[0]?.error).toBe( + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe( 'Unable to load SVG file: /fixtures/not-found.svg', ) + expect(result.afterEachCalls[0]!.svg).toBe(null) }) test('invalid src error', async ({ page }) => { @@ -397,9 +446,11 @@ test.describe('SVGInjector', () => { }) expect(result.elementsLoaded).toBe(1) - expect(result.afterEachCalls[0]?.error).toBe( + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe( 'Invalid data-src or src attribute', ) + expect(result.afterEachCalls[0]!.svg).toBe(null) }) test('unknown exception', async ({ page }) => { @@ -422,12 +473,15 @@ test.describe('SVGInjector', () => { selector: '.inject-me', }) - expect(result.afterEachCalls[0]?.error).toBe( + expect(result.elementsLoaded).toBe(1) + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe( 'There was a problem injecting the SVG: 500 Internal Server Error', ) + expect(result.afterEachCalls[0]!.svg).toBe(null) }) - test('default `afterAll` callback', async ({ page }) => { + test('completes without explicit `afterAll`', async ({ page }) => { await setupPage(page) const result = await page.evaluate(() => { @@ -535,9 +589,12 @@ test.describe('SVGInjector', () => { options: { cacheRequests: false }, }) - expect(result.afterEachCalls[0]?.error).toBe( + expect(result.elementsLoaded).toBe(1) + expect(result.afterEachCalls).toHaveLength(1) + expect(result.afterEachCalls[0]!.error).toBe( 'There was a problem injecting the SVG: 500 Internal Server Error', ) + expect(result.afterEachCalls[0]!.svg).toBe(null) }) test('returns an error when parent node is null', async ({ page }) => {