diff --git a/docker/cdn_test.go b/docker/cdn_test.go
new file mode 100644
index 00000000..cbcec59b
--- /dev/null
+++ b/docker/cdn_test.go
@@ -0,0 +1,407 @@
+package docker_test
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// cdnURLPattern is the same regex used by download_cdn.sh to extract CDN URLs.
+var cdnURLPattern = regexp.MustCompile(`https://cdn\.jsdelivr\.net/npm/[^"' )\x60]*`)
+
+// HTML fixtures that mirror the real www/*.html files.
+var htmlFixtures = map[string]string{
+ "hls.html": `
+
+
+
+
+
+`,
+
+ "config.html": `
+
+
+
+
+
+
+`,
+
+ "net.html": `
+
+
+
+
+
+`,
+
+ "links.html": `
+
+
+
+
+`,
+}
+
+func parseCDNURL(rawURL string) (pkgName, filePath, localURL string) {
+ npmPath := strings.TrimPrefix(rawURL, "https://cdn.jsdelivr.net/npm/")
+
+ parts := strings.SplitN(npmPath, "/", 2)
+ pkgVer := parts[0]
+
+ if len(parts) > 1 {
+ filePath = parts[1]
+ }
+
+ // Remove @version suffix to get package name
+ if idx := strings.LastIndex(pkgVer, "@"); idx > 0 {
+ pkgName = pkgVer[:idx]
+ } else {
+ pkgName = pkgVer
+ }
+
+ if filePath != "" {
+ localURL = "cdn/" + pkgName + "/" + filePath
+ } else {
+ localURL = "cdn/" + pkgName + "/index.js"
+ }
+ return
+}
+
+// extractURLs finds all CDN URLs in the given HTML content.
+func extractURLs(htmlFiles map[string]string) []string {
+ seen := map[string]bool{}
+ for _, content := range htmlFiles {
+ for _, match := range cdnURLPattern.FindAllString(content, -1) {
+ seen[match] = true
+ }
+ }
+ urls := make([]string, 0, len(seen))
+ for u := range seen {
+ urls = append(urls, u)
+ }
+ sort.Strings(urls)
+ return urls
+}
+
+// patchHTML replaces all CDN URLs in content with local paths.
+func patchHTML(content string, urls []string) string {
+ for _, u := range urls {
+ _, _, localURL := parseCDNURL(u)
+ content = strings.ReplaceAll(content, u, localURL)
+ }
+ return content
+}
+
+func TestExtractURLs(t *testing.T) {
+ urls := extractURLs(htmlFixtures)
+
+ expected := []string{
+ "https://cdn.jsdelivr.net/npm/hls.js@1",
+ "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
+ "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min",
+ "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js",
+ "https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js",
+ "https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js",
+ }
+
+ if len(urls) != len(expected) {
+ t.Fatalf("expected %d URLs, got %d: %v", len(expected), len(urls), urls)
+ }
+ for i, u := range urls {
+ if u != expected[i] {
+ t.Errorf("URL[%d]: expected %q, got %q", i, expected[i], u)
+ }
+ }
+}
+
+func TestParseCDNURL(t *testing.T) {
+ tests := []struct {
+ url string
+ pkgName string
+ filePath string
+ localURL string
+ }{
+ {
+ url: "https://cdn.jsdelivr.net/npm/hls.js@1",
+ pkgName: "hls.js",
+ filePath: "",
+ localURL: "cdn/hls.js/index.js",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
+ pkgName: "js-yaml",
+ filePath: "dist/js-yaml.min.js",
+ localURL: "cdn/js-yaml/dist/js-yaml.min.js",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js",
+ pkgName: "monaco-editor",
+ filePath: "min/vs/loader.js",
+ localURL: "cdn/monaco-editor/min/vs/loader.js",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min",
+ pkgName: "monaco-editor",
+ filePath: "min",
+ localURL: "cdn/monaco-editor/min",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js",
+ pkgName: "vis-network",
+ filePath: "standalone/umd/vis-network.min.js",
+ localURL: "cdn/vis-network/standalone/umd/vis-network.min.js",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js",
+ pkgName: "qrcodejs",
+ filePath: "qrcode.min.js",
+ localURL: "cdn/qrcodejs/qrcode.min.js",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.pkgName, func(t *testing.T) {
+ pkgName, filePath, localURL := parseCDNURL(tt.url)
+ if pkgName != tt.pkgName {
+ t.Errorf("pkgName: expected %q, got %q", tt.pkgName, pkgName)
+ }
+ if filePath != tt.filePath {
+ t.Errorf("filePath: expected %q, got %q", tt.filePath, filePath)
+ }
+ if localURL != tt.localURL {
+ t.Errorf("localURL: expected %q, got %q", tt.localURL, localURL)
+ }
+ })
+ }
+}
+
+func TestPatchHTML(t *testing.T) {
+ urls := extractURLs(htmlFixtures)
+
+ t.Run("hls", func(t *testing.T) {
+ patched := patchHTML(htmlFixtures["hls.html"], urls)
+ if !strings.Contains(patched, `src="cdn/hls.js/index.js"`) {
+ t.Error("hls.js src not patched")
+ }
+ if strings.Contains(patched, "cdn.jsdelivr.net") {
+ t.Error("CDN URL still present")
+ }
+ if !strings.Contains(patched, `