diff --git a/packages/webamp/js/playlistHtml.test.ts b/packages/webamp/js/playlistHtml.test.ts new file mode 100644 index 00000000..078026b9 --- /dev/null +++ b/packages/webamp/js/playlistHtml.test.ts @@ -0,0 +1,91 @@ +import { createPlaylistURL, getAsDataURI } from "./playlistHtml"; + +function base64ToUtf8(str: string): string { + return decodeURIComponent( + Array.prototype.map + .call( + atob(str), + (c: string) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}` + ) + .join("") + ); +} + +describe("playlistHtml", () => { + describe("createPlaylistURL", () => { + it("handles track names with characters outside Latin-1 range", () => { + const props = { + averageTrackLength: "3:45", + numberOfTracks: 3, + playlistLengthSeconds: 15, + playlistLengthMinutes: 11, + tracks: [ + "Song with emoji 🎵🎶", + "中文歌曲名称.mp3", + "Песня на русском.mp3", + ], + }; + + const result = createPlaylistURL(props); + + // Should be a valid data URI + expect(result).toMatch(/^data:text\/html;base64,/); + + // Decode the base64 to check the content + const base64Content = result.replace("data:text/html;base64,", ""); + const decodedHTML = base64ToUtf8(base64Content); + + // Check that all track names are present in the decoded HTML + expect(decodedHTML).toContain("Song with emoji 🎵🎶"); + expect(decodedHTML).toContain("中文歌曲名称.mp3"); + expect(decodedHTML).toContain("Песня на русском.mp3"); + + // Verify playlist metadata is included + expect(decodedHTML).toContain("3"); + expect(decodedHTML).toContain("3:45"); + expect(decodedHTML).toContain("11"); + expect(decodedHTML).toContain("15"); + }); + + it("creates valid HTML with basic track names", () => { + const props = { + averageTrackLength: "4:20", + numberOfTracks: 1, + playlistLengthSeconds: 20, + playlistLengthMinutes: 4, + tracks: ["test-track.mp3"], + }; + + const result = createPlaylistURL(props); + + expect(result).toMatch(/^data:text\/html;base64,/); + + const base64Content = result.replace("data:text/html;base64,", ""); + const decodedHTML = atob(base64Content); + + expect(decodedHTML).toContain(""); + expect(decodedHTML).toContain("test-track.mp3"); + expect(decodedHTML).toContain("Winamp Generated PlayList"); + }); + }); + + describe("getAsDataURI", () => { + it("converts text to base64 data URI", () => { + const text = "Hello, World!"; + const result = getAsDataURI(text); + + expect(result).toBe("data:text/html;base64,SGVsbG8sIFdvcmxkIQ=="); + }); + + it("handles text with HTML tags", () => { + const text = "Test"; + const result = getAsDataURI(text); + + expect(result).toMatch(/^data:text\/html;base64,/); + + const base64Content = result.replace("data:text/html;base64,", ""); + const decoded = atob(base64Content); + expect(decoded).toBe(text); + }); + }); +}); diff --git a/packages/webamp/js/playlistHtml.tsx b/packages/webamp/js/playlistHtml.tsx index deed977b..f31a1a0a 100644 --- a/packages/webamp/js/playlistHtml.tsx +++ b/packages/webamp/js/playlistHtml.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; @@ -9,8 +10,15 @@ interface Props { tracks: string[]; } -export const getAsDataURI = (text: string): string => - `data:text/html;base64,${window.btoa(text)}`; +export const getAsDataURI = (text: string): string => { + // Properly encode UTF-8 to base64 + // btoa() only handles Latin-1 (ISO-8859-1), so we need to encode UTF-8 first + const utf8Bytes = encodeURIComponent(text).replace( + /%([0-9A-F]{2})/g, + (_, p1) => String.fromCharCode(parseInt(p1, 16)) + ); + return `data:text/html;base64,${window.btoa(utf8Bytes)}`; +}; // Replaces deprecated "noshade" attribute const noshadeStyle = {