diff --git a/Makefile b/Makefile index cf1c4713d..bcccbe968 100644 --- a/Makefile +++ b/Makefile @@ -274,8 +274,9 @@ envtest: .PHONY: unit-tests unit-tests: envtest KUBEBUILDER_ASSETS="$(shell ./operator/bin/setup-envtest use $(ENVTEST_K8S_VERSION) --bin-dir $(shell pwd)/operator/bin -p path)" \ - go test -tags $(GO_BUILD_TAGS) -v ./pkg/... ./cmd/... + go test -tags $(GO_BUILD_TAGS) -v ./pkg/... ./cmd/... ./api/... ./web/... ./pkg-new/... $(MAKE) -C operator test + $(MAKE) -C utils unit-tests .PHONY: vet vet: diff --git a/api/api.go b/api/api.go index f91e9e5e8..484061b3f 100644 --- a/api/api.go +++ b/api/api.go @@ -124,7 +124,6 @@ func (a *API) RegisterRoutes(router *mux.Router) { router.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) router.HandleFunc("/auth/login", a.postAuthLogin).Methods("POST") - router.HandleFunc("/branding", a.getBranding).Methods("GET") authenticatedRouter := router.PathPrefix("/").Subrouter() authenticatedRouter.Use(a.authMiddleware) diff --git a/api/console.go b/api/console.go index 3f9d60043..3e7c3a2bc 100644 --- a/api/console.go +++ b/api/console.go @@ -2,29 +2,8 @@ package api import ( "net/http" - - "github.com/replicatedhq/embedded-cluster/api/types" ) -type getBrandingResponse struct { - Branding types.Branding `json:"branding"` -} - -func (a *API) getBranding(w http.ResponseWriter, r *http.Request) { - branding, err := a.consoleController.GetBranding() - if err != nil { - a.logError(r, err, "failed to get branding") - a.jsonError(w, r, err) - return - } - - response := getBrandingResponse{ - Branding: branding, - } - - a.json(w, r, http.StatusOK, response) -} - type getListAvailableNetworkInterfacesResponse struct { NetworkInterfaces []string `json:"networkInterfaces"` } diff --git a/api/controllers/console/controller.go b/api/controllers/console/controller.go index 61214b06b..66ede9f4e 100644 --- a/api/controllers/console/controller.go +++ b/api/controllers/console/controller.go @@ -1,15 +1,10 @@ package console import ( - "fmt" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" - "github.com/replicatedhq/embedded-cluster/api/types" - "github.com/replicatedhq/embedded-cluster/pkg/release" ) type Controller interface { - GetBranding() (types.Branding, error) ListAvailableNetworkInterfaces() ([]string, error) } @@ -41,18 +36,6 @@ func NewConsoleController(opts ...ConsoleControllerOption) (*ConsoleController, return controller, nil } -func (c *ConsoleController) GetBranding() (types.Branding, error) { - app := release.GetApplication() - if app == nil { - return types.Branding{}, fmt.Errorf("application not found") - } - - return types.Branding{ - AppTitle: app.Spec.Title, - AppIcon: app.Spec.Icon, - }, nil -} - func (c *ConsoleController) ListAvailableNetworkInterfaces() ([]string, error) { return c.NetUtils.ListValidNetworkInterfaces() } diff --git a/api/types/console.go b/api/types/console.go deleted file mode 100644 index 05ad6d44d..000000000 --- a/api/types/console.go +++ /dev/null @@ -1,6 +0,0 @@ -package types - -type Branding struct { - AppTitle string `json:"appTitle"` - AppIcon string `json:"appIcon"` -} diff --git a/api/types/errors_test.go b/api/types/errors_test.go index dac6f0cfd..b186d0aef 100644 --- a/api/types/errors_test.go +++ b/api/types/errors_test.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -224,94 +223,3 @@ func TestAPIError_ErrorOrNil(t *testing.T) { }) } } - -func TestAPIError_JSON(t *testing.T) { - tests := []struct { - name string - apiErr *APIError - wantCode int - wantJSON map[string]any - }{ - { - name: "simple error", - apiErr: &APIError{ - StatusCode: http.StatusInternalServerError, - Message: "invalid request", - }, - wantCode: http.StatusInternalServerError, - wantJSON: map[string]any{ - "status_code": float64(http.StatusInternalServerError), - "message": "invalid request", - }, - }, - { - name: "field error", - apiErr: &APIError{ - StatusCode: http.StatusBadRequest, - Message: "validation error", - Field: "username", - }, - wantCode: http.StatusBadRequest, - wantJSON: map[string]any{ - "status_code": float64(http.StatusBadRequest), - "message": "validation error", - "field": "username", - }, - }, - { - name: "error with nested errors", - apiErr: &APIError{ - StatusCode: http.StatusBadRequest, - Message: "multiple validation errors", - Errors: []*APIError{ - { - Message: "field1 is required", - Field: "field1", - }, - { - Message: "field2 must be a number", - Field: "field2", - }, - }, - }, - wantCode: http.StatusBadRequest, - wantJSON: map[string]any{ - "status_code": float64(http.StatusBadRequest), - "message": "multiple validation errors", - "errors": []any{ - map[string]any{ - "message": "field1 is required", - "field": "field1", - }, - map[string]any{ - "message": "field2 must be a number", - "field": "field2", - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock HTTP response recorder - rec := httptest.NewRecorder() - - // Call the JSON method - tt.apiErr.JSON(rec) - - // Check status code - assert.Equal(t, tt.wantCode, rec.Code, "Status code should match") - - // Check content type header - contentType := rec.Header().Get("Content-Type") - assert.Equal(t, "application/json", contentType, "Content-Type header should be application/json") - - // Parse and check the JSON response - var gotJSON map[string]any - err := json.Unmarshal(rec.Body.Bytes(), &gotJSON) - assert.NoError(t, err, "Should be able to parse the JSON response") - assert.Equal(t, tt.wantJSON, gotJSON, "JSON response should match expected structure") - }) - } -} diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index d18f75016..9c46d41de 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "log" "net" "net/http" @@ -97,6 +98,9 @@ type InstallCmdFlags struct { tlsKeyBytes []byte } +// webAssetsFS is the filesystem to be used by the web component. Defaults to nil allowing the web server to use the default assets embedded in the binary. Useful for testing. +var webAssetsFS fs.FS = nil + // InstallCmd returns a cobra command for installing the embedded cluster. func InstallCmd(ctx context.Context, name string) *cobra.Command { var flags InstallCmdFlags @@ -455,16 +459,21 @@ func runInstallAPI(ctx context.Context, listener net.Listener, cert tls.Certific if err != nil { return fmt.Errorf("new api: %w", err) } + app := release.GetApplication() + if app == nil { + return fmt.Errorf("application not found") + } - api.RegisterRoutes(router.PathPrefix("/api").Subrouter()) - - var webFs http.Handler - if os.Getenv("EC_DEV_ENV") == "true" { - webFs = http.FileServer(http.FS(os.DirFS("./web/dist"))) - } else { - webFs = http.FileServer(http.FS(web.Fs())) + webServer, err := web.New(web.InitialState{ + Title: app.Spec.Title, + Icon: app.Spec.Icon, + }, web.WithLogger(logger), web.WithAssetsFS(webAssetsFS)) + if err != nil { + return fmt.Errorf("new web server: %w", err) } - router.PathPrefix("/").Methods("GET").Handler(webFs) + + api.RegisterRoutes(router.PathPrefix("/api").Subrouter()) + webServer.RegisterRoutes(router.PathPrefix("/").Subrouter()) server := &http.Server{ // ErrorLog outputs TLS errors and warnings to the console, we want to make sure we use the same logrus logger for them diff --git a/cmd/installer/cli/install_test.go b/cmd/installer/cli/install_test.go index 908d52e56..1304c7144 100644 --- a/cmd/installer/cli/install_test.go +++ b/cmd/installer/cli/install_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "testing" + "testing/fstest" "time" "github.com/replicatedhq/embedded-cluster/api" @@ -560,6 +561,29 @@ func Test_runInstallAPI(t *testing.T) { certPool := x509.NewCertPool() certPool.AddCert(cert.Leaf) + // We need a release object to pass over to the Web component. + dataMap := map[string][]byte{ + "kots-app.yaml": []byte(` +apiVersion: kots.io/v1beta1 +kind: Application +`), + } + err = release.SetReleaseDataForTests(dataMap) + require.NoError(t, err) + + t.Cleanup(func() { + release.SetReleaseDataForTests(nil) + }) + + // Mock the web assets filesystem so that we don't need to embed the web assets. + webAssetsFS = fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte(""), + Mode: 0644, + }, + } + defer func() { webAssetsFS = nil }() + go func() { err := runInstallAPI(ctx, listener, cert, logger, "password", nil) t.Logf("Install API exited with error: %v", err) @@ -581,7 +605,7 @@ func Test_runInstallAPI(t *testing.T) { }, } resp, err := httpClient.Get(url) - assert.NoError(t, err) + require.NoError(t, err) if resp != nil { defer resp.Body.Close() } diff --git a/web/dist/README.md b/web/dist/README.md index e69de29bb..7f131d6a4 100644 --- a/web/dist/README.md +++ b/web/dist/README.md @@ -0,0 +1 @@ +Keeping this file as a placeholder for //got:embed dist to work diff --git a/web/index.html b/web/index.html index f61af318d..31c752628 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,11 @@ - Gitea Enterprise Installer + {{ .Title }} + +
diff --git a/web/src/components/common/Logo.tsx b/web/src/components/common/Logo.tsx index a92d850a7..7919dc2fb 100644 --- a/web/src/components/common/Logo.tsx +++ b/web/src/components/common/Logo.tsx @@ -3,13 +3,13 @@ import React from 'react'; import { useBranding } from '../../contexts/BrandingContext'; export const AppIcon: React.FC<{ className?: string }> = ({ className = 'w-6 h-6' }) => { - const { branding } = useBranding(); - if (!branding?.appIcon) { + const { icon } = useBranding(); + if (!icon) { return
; } return ( App Icon diff --git a/web/src/components/wizard/CompletionStep.tsx b/web/src/components/wizard/CompletionStep.tsx index 8e7e33064..16a247b21 100644 --- a/web/src/components/wizard/CompletionStep.tsx +++ b/web/src/components/wizard/CompletionStep.tsx @@ -7,14 +7,14 @@ import { CheckCircle, ExternalLink, Copy, ClipboardCheck } from 'lucide-react'; const CompletionStep: React.FC = () => { const { config, prototypeSettings } = useConfig(); - const { branding } = useBranding(); + const { title } = useBranding(); const [copied, setCopied] = useState(false); const themeColor = prototypeSettings.themeColor; const baseUrl = `${config.useHttps ? 'https' : 'http'}://${config.domain}`; const urls = [ - { name: 'Web Interface', url: baseUrl, description: `Access the main ${branding?.appTitle} interface` }, - { name: 'API Documentation', url: `${baseUrl}/api/swagger`, description: `Browse and test the ${branding?.appTitle} API` } + { name: 'Web Interface', url: baseUrl, description: `Access the main ${title} interface` }, + { name: 'API Documentation', url: `${baseUrl}/api/swagger`, description: `Browse and test the ${title} API` } ]; const copyToClipboard = (text: string) => { @@ -33,9 +33,9 @@ const CompletionStep: React.FC = () => {

Installation Complete!

- {branding?.appTitle} is installed successfully. + {title} is installed successfully.

- + +
+

Proxy Configuration

+
+ - {showAdvanced && ( -
- + + +
+
+ +
+ + + {showAdvanced && ( +
+ -
- )} -
+ ) + ]} + helpText={`Network interface to use for ${title}`} + /> + + +
+ )} + ); }; -export default LinuxSetup; \ No newline at end of file +export default LinuxSetup; diff --git a/web/src/contexts/BrandingContext.tsx b/web/src/contexts/BrandingContext.tsx index eb954f83a..9957d4041 100644 --- a/web/src/contexts/BrandingContext.tsx +++ b/web/src/contexts/BrandingContext.tsx @@ -1,17 +1,11 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useContext } from 'react'; interface Branding { - appTitle: string; - appIcon?: string; + title: string; + icon?: string; } -interface BrandingContextType { - branding: Branding | null; - isLoading: boolean; - error: Error | null; -} - -const BrandingContext = createContext(undefined); +const BrandingContext = createContext({ title: "My App" }); export const useBranding = () => { const context = useContext(BrandingContext); @@ -22,44 +16,17 @@ export const useBranding = () => { }; export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [branding, setBranding] = useState({ appTitle: "App" }); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (branding?.appTitle) { - document.title = branding.appTitle; - } - }, [branding?.appTitle]); - - useEffect(() => { - const fetchBranding = async () => { - try { - const response = await fetch('/api/branding', { - headers: { - // Include auth credentials if available from localStorage or another source - ...(localStorage.getItem('auth') && { - 'Authorization': `Bearer ${localStorage.getItem('auth')}`, - }), - }, - }); - if (!response.ok) { - throw new Error('Failed to fetch branding'); - } - const data = await response.json(); - setBranding(data.branding); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch branding')); - } finally { - setIsLoading(false); - } - }; + // __INITIAL_STATE__ is a global variable that can be set by the server-side rendering process + // as a way to pass initial data to the client. + const initialState = window.__INITIAL_STATE__ || {}; - fetchBranding(); - }, []); + const branding = { + title: initialState.title || "My App", + icon: initialState.icon, + }; return ( - + {children} ); diff --git a/web/src/contexts/WizardModeContext.tsx b/web/src/contexts/WizardModeContext.tsx index f0257f90b..a7bbea6b6 100644 --- a/web/src/contexts/WizardModeContext.tsx +++ b/web/src/contexts/WizardModeContext.tsx @@ -21,36 +21,36 @@ interface WizardText { nextButtonText: string; } -const getTextVariations = (isEmbedded: boolean, appTitle: string): Record => ({ +const getTextVariations = (isEmbedded: boolean, title: string): Record => ({ install: { - title: appTitle || '', + title: title || '', subtitle: 'Installation Wizard', - welcomeTitle: `Welcome to ${appTitle}`, - welcomeDescription: `This wizard will guide you through installing ${appTitle} on your ${isEmbedded ? 'Linux machine' : 'Kubernetes cluster'}.`, + welcomeTitle: `Welcome to ${title}`, + welcomeDescription: `This wizard will guide you through installing ${title} on your ${isEmbedded ? 'Linux machine' : 'Kubernetes cluster'}.`, setupTitle: 'Setup', setupDescription: 'Set up the hosts to use for this installation.', configurationTitle: 'Configuration', - configurationDescription: `Configure your ${appTitle} installation by providing the information below.`, - installationTitle: `Installing ${appTitle}`, + configurationDescription: `Configure your ${title} installation by providing the information below.`, + installationTitle: `Installing ${title}`, installationDescription: '', completionTitle: 'Installation Complete!', - completionDescription: `${appTitle} has been installed successfully.`, + completionDescription: `${title} has been installed successfully.`, welcomeButtonText: 'Start', nextButtonText: 'Next: Start Installation', }, upgrade: { - title: appTitle || '', + title: title || '', subtitle: 'Upgrade Wizard', - welcomeTitle: `Welcome to ${appTitle}`, - welcomeDescription: `This wizard will guide you through upgrading ${appTitle} on your ${isEmbedded ? 'Linux machine' : 'Kubernetes cluster'}.`, + welcomeTitle: `Welcome to ${title}`, + welcomeDescription: `This wizard will guide you through upgrading ${title} on your ${isEmbedded ? 'Linux machine' : 'Kubernetes cluster'}.`, setupTitle: 'Setup', setupDescription: 'Set up the hosts to use for this installation.', configurationTitle: 'Upgrade Configuration', - configurationDescription: `Configure your ${appTitle} installation by providing the information below.`, - installationTitle: `Upgrading ${appTitle}`, + configurationDescription: `Configure your ${title} installation by providing the information below.`, + installationTitle: `Upgrading ${title}`, installationDescription: '', completionTitle: 'Upgrade Complete!', - completionDescription: `${appTitle} has been successfully upgraded.`, + completionDescription: `${title} has been successfully upgraded.`, welcomeButtonText: 'Start Upgrade', nextButtonText: 'Next: Start Upgrade', }, @@ -68,9 +68,9 @@ export const WizardModeProvider: React.FC<{ mode: WizardMode; }> = ({ children, mode }) => { const { prototypeSettings } = useConfig(); - const { branding } = useBranding(); + const { title } = useBranding(); const isEmbedded = prototypeSettings.clusterMode === 'embedded'; - const text = getTextVariations(isEmbedded, branding?.appTitle || '')[mode]; + const text = getTextVariations(isEmbedded, title)[mode]; return ( @@ -85,4 +85,4 @@ export const useWizardMode = (): WizardModeContextType => { throw new Error('useWizardMode must be used within a WizardModeProvider'); } return context; -}; \ No newline at end of file +}; diff --git a/web/src/global.d.ts b/web/src/global.d.ts new file mode 100644 index 000000000..ab361cdbe --- /dev/null +++ b/web/src/global.d.ts @@ -0,0 +1,14 @@ +// src/global.d.ts +export { }; + +declare global { + interface Window { + __INITIAL_STATE__?: InitialState; + } + + // Initial state is how the server can pass initial data to the client. + interface InitialState { + icon?: string; + title?: string; + } +} diff --git a/web/static.go b/web/static.go index 0d3aef520..8476c44f3 100644 --- a/web/static.go +++ b/web/static.go @@ -1,23 +1,139 @@ package web import ( + "bytes" "embed" + "encoding/json" + "fmt" + "html/template" "io/fs" + "net/http" + "os" + + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" ) //go:embed dist var static embed.FS -var staticFS fs.FS +var embedAssetsFS fs.FS func init() { var err error - staticFS, err = fs.Sub(static, "dist") + embedAssetsFS, err = fs.Sub(static, "dist") if err != nil { panic(err) } } -func Fs() fs.FS { - return staticFS +type InitialState struct { + Title string `json:"title"` + Icon string `json:"icon"` +} + +type Web struct { + // htmlTemplate is the parsed HTML template for the React app + htmlTemplate *template.Template + // assets is the filesystem containing static assets + assets fs.FS + // initialState is the initial state to be passed to the React app + initialState InitialState + logger logrus.FieldLogger +} + +type WebOption func(*Web) + +func WithLogger(logger logrus.FieldLogger) WebOption { + return func(web *Web) { + web.logger = logger + } +} + +func WithAssetsFS(assets fs.FS) WebOption { + return func(web *Web) { + web.assets = assets + } +} + +// DefaultAssetsFS returns the default filesystem containing static assets +func DefaultAssetsFS() fs.FS { + return embedAssetsFS +} + +// New creates a new Web instance with the provided initial state and options +func New(initialState InitialState, opts ...WebOption) (*Web, error) { + web := &Web{initialState: initialState} + for _, opt := range opts { + opt(web) + } + + if web.logger == nil { + web.logger = logrus.New().WithField("component", "web") + } + + if web.assets == nil { + web.assets = DefaultAssetsFS() + } + + if web.htmlTemplate == nil { + htmlTemplate, err := template.ParseFS(web.assets, "index.html") + if err != nil { + return nil, fmt.Errorf("failed to parse HTML template: %w", err) + } + web.htmlTemplate = htmlTemplate + } + + return web, nil +} + +func (web *Web) rootHandler(w http.ResponseWriter, r *http.Request) { + stateJSON, err := json.Marshal(web.initialState) + if err != nil { + web.logger.WithError(err). + Info("failed to marshal initial state") + http.Error(w, "Error marshaling initial state", 500) + return + } + + data := struct { + Title string + InitialState template.JS + }{ + Title: web.initialState.Title, + // State we're passing directly to the React app + InitialState: template.JS(stateJSON), // Mark safe for unescaped JS + } + + // Create a buffer to store the rendered template + buf := new(bytes.Buffer) + + // Execute the template and write to the buffer + err = web.htmlTemplate.Execute(buf, data) + if err != nil { + web.logger.WithError(err). + Info("failed to execute HTML template") + http.Error(w, "Template execution error", 500) + } + + // Write the buffer contents to the response writer + _, err = buf.WriteTo(w) + if err != nil { + web.logger.WithError(err). + Info("failed to write response") + return + } +} + +func (web *Web) RegisterRoutes(router *mux.Router) { + + var webFS http.Handler + if os.Getenv("EC_DEV_ENV") == "true" { + webFS = http.FileServer(http.FS(os.DirFS("./web/dist/assets"))) + } else { + webFS = http.FileServer(http.FS(web.assets)) + } + + router.PathPrefix("/assets").Methods("GET").Handler(webFS) + router.PathPrefix("/").Methods("GET").HandlerFunc(web.rootHandler) } diff --git a/web/static_test.go b/web/static_test.go new file mode 100644 index 000000000..5207d54ac --- /dev/null +++ b/web/static_test.go @@ -0,0 +1,316 @@ +package web + +import ( + "encoding/json" + "html/template" + "net/http" + "net/http/httptest" + "os" + "testing" + "testing/fstest" + + "github.com/gorilla/mux" + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// overrideDefaultFS temporarily replaces the assetsFS with a mock filesystem and returns a cleanup function +func overrideDefaultFS(mockFS fstest.MapFS) func() { + // Store the original assetsFS + originalAssetsFS := embedAssetsFS + + // Replace with mock filesystem + embedAssetsFS = mockFS + + // Return cleanup function + return func() { + embedAssetsFS = originalAssetsFS + } +} + +// createMockFS creates a standard mock filesystem for testing +func createMockFS() fstest.MapFS { + // Create mock assets + return fstest.MapFS{ + "assets/test-icon.png": &fstest.MapFile{ + Data: []byte("fake icon data"), + Mode: 0644, + }, + "assets/app.js": &fstest.MapFile{ + Data: []byte("console.log('Hello, world!');"), + Mode: 0644, + }, + "index.html": &fstest.MapFile{ + Data: []byte(` + + + {{.Title}} + + + + + `, + ), + Mode: 0644, + }, + } +} + +func TestNew(t *testing.T) { + // Create initial state + initialState := InitialState{ + Title: "Test Title", + Icon: "test-icon.png", + } + + // Create a test logger + logger, _ := logtest.NewNullLogger() + + // Create a new Web instance + web, err := New(initialState, WithLogger(logger), WithAssetsFS(createMockFS())) + require.NoError(t, err, "Failed to create Web instance") + + // Verify the web instance was created correctly + assert.Equal(t, initialState.Title, web.initialState.Title, "Title should match") + assert.Equal(t, initialState.Icon, web.initialState.Icon, "Icon should match") + assert.NotNil(t, web.logger, "Logger should be set") + assert.NotNil(t, web.htmlTemplate, "HTML template should be set") +} + +func TestNewWithDefaultFS(t *testing.T) { + // Override the default filesystem with a mock one + cleanup := overrideDefaultFS(createMockFS()) + defer cleanup() + + // Create initial state + initialState := InitialState{ + Title: "Test Title", + Icon: "test-icon.png", + } + + // Create a test logger + logger, _ := logtest.NewNullLogger() + + // Create a new Web instance + web, err := New(initialState, WithLogger(logger)) + require.NoError(t, err, "Failed to create Web instance") + + // Verify the web instance was created correctly + assert.Equal(t, initialState.Title, web.initialState.Title, "Title should match") + assert.Equal(t, initialState.Icon, web.initialState.Icon, "Icon should match") + assert.NotNil(t, web.logger, "Logger should be set") + assert.NotNil(t, web.htmlTemplate, "HTML template should be set") +} + +// TestNewWithIndexHTML tests creating a Web instance with the actual index.html template we use and pass over to Vite for building. +func TestNewWithIndexHTML(t *testing.T) { + // Setup a mock filesystem with our actual index.html file + indexHTML, err := os.ReadFile("index.html") + require.NoError(t, err, "Failed to read index.html") + + mockFS := fstest.MapFS{ + "assets/test-icon.png": &fstest.MapFile{ + Data: []byte("fake icon data"), + Mode: 0644, + }, + "assets/app.js": &fstest.MapFile{ + Data: []byte("console.log('Hello, world!');"), + Mode: 0644, + }, + "index.html": &fstest.MapFile{ + Data: indexHTML, + Mode: 0644, + }, + } + + // Create initial state + initialState := InitialState{ + Title: "Test Title", + Icon: "test-icon.png", + } + + // Create a test logger + logger, _ := logtest.NewNullLogger() + + // Create a new Web instance, using the actual index.html template + web, err := New(initialState, WithLogger(logger), WithAssetsFS(mockFS)) + require.NoError(t, err, "Failed to create Web instance") + + // Verify the web instance was created correctly + assert.Equal(t, initialState.Title, web.initialState.Title, "Title should match") + assert.Equal(t, initialState.Icon, web.initialState.Icon, "Icon should match") +} + +func TestNewWithNonExistentTemplate(t *testing.T) { + // Setup a mock filesystem without an index.html file + mockFS := fstest.MapFS{ + "assets/test-icon.png": &fstest.MapFile{ + Data: []byte("fake icon data"), + Mode: 0644, + }, + "assets/app.js": &fstest.MapFile{ + Data: []byte("console.log('Hello, world!');"), + Mode: 0644, + }, + // Deliberately omitting index.html + } + + // Create initial state + initialState := InitialState{ + Title: "Test Title", + Icon: "test-icon.png", + } + + // Create a test logger + logger, _ := logtest.NewNullLogger() + + // Try to create a new Web instance without providing an HTML template + web, err := New(initialState, WithLogger(logger), WithAssetsFS(mockFS)) + + // Assert that an error was returned + assert.Error(t, err, "New should return an error when the template doesn't exist") + + // Assert that the error message mentions the template + assert.Contains(t, err.Error(), "failed to parse HTML template", "Error should mention template parsing failure") + + // Assert that the web instance is nil + assert.Nil(t, web, "Web instance should be nil when an error occurs") +} + +func TestRootHandler(t *testing.T) { + initialState := InitialState{ + Title: "Test Title", + Icon: "test-icon.png", + } + + // Create a test logger + logger, _ := logtest.NewNullLogger() + + // Create a new Web instance + web, err := New(initialState, WithLogger(logger), WithAssetsFS(createMockFS())) + require.NoError(t, err, "Failed to create Web instance") + + // Create a mock HTTP request + req := httptest.NewRequest("GET", "/", nil) + recorder := httptest.NewRecorder() + + // Call the rootHandler + web.rootHandler(recorder, req) + + // Check status code + assert.Equal(t, http.StatusOK, recorder.Code, "Should return status OK") + + // Read the response body + body := recorder.Body.String() + + // Check that the title is in the response + assert.Contains(t, body, initialState.Title, "Response should contain the title") + + // Check that the initial state JSON is in the response + stateJSON, _ := json.Marshal(initialState) + assert.Contains(t, body, string(stateJSON), "Response should contain initial state JSON") +} + +func TestRootHandlerTemplateError(t *testing.T) { + // Create initial state + initialState := InitialState{ + Title: "Test Title", + Icon: "test-icon.png", + } + + // Create a test logger + logger, _ := logtest.NewNullLogger() + + // Create a new Web instance + web, err := New(initialState, WithLogger(logger), WithAssetsFS(createMockFS())) + require.NoError(t, err, "Failed to create Web instance") + + // Replace the template with one that will cause an error + errorTemplate, err := template.New("error").Parse("{{.NonExistentField}}") + assert.NoError(t, err, "Failed to parse error template") + + // Save original and replace with error template + originalTemplate := web.htmlTemplate + web.htmlTemplate = errorTemplate + defer func() { web.htmlTemplate = originalTemplate }() + + // Create a mock HTTP request + req := httptest.NewRequest("GET", "/", nil) + recorder := httptest.NewRecorder() + + // Call the rootHandler + web.rootHandler(recorder, req) + + // Check status code + assert.Equal(t, http.StatusInternalServerError, recorder.Code, "Should return internal server error") + + // Check that the error message is in the response + expectedError := "Template execution error" + assert.Contains(t, recorder.Body.String(), expectedError, "Response should contain error message") +} + +func TestRegisterRoutes(t *testing.T) { + // Create initial state + initialState := InitialState{ + Title: "Test Title", + Icon: "test-icon.png", + } + + // Create a test logger + logger, _ := logtest.NewNullLogger() + + // Create a new Web instance + web, err := New(initialState, WithLogger(logger), WithAssetsFS(createMockFS())) + require.NoError(t, err, "Failed to create Web instance") + + // Create router + router := mux.NewRouter() + web.RegisterRoutes(router) + + t.Run("Root Path", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + recorder := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(recorder, req) + + // Check status code + assert.Equal(t, http.StatusOK, recorder.Code, "Should return status OK") + + // Check that the title is in the response + assert.Contains(t, recorder.Body.String(), initialState.Title, "Response should contain the title") + }) + + // Test 2: Icon asset + t.Run("Icon Asset", func(t *testing.T) { + req := httptest.NewRequest("GET", "/assets/"+initialState.Icon, nil) + recorder := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(recorder, req) + + // Check status code + assert.Equal(t, http.StatusOK, recorder.Code, "Should return status OK for icon") + + // Check that the icon content is in the response + assert.Equal(t, "fake icon data", recorder.Body.String(), "Response should contain the icon content") + }) + + // Test 3: JS asset + t.Run("JS Asset", func(t *testing.T) { + req := httptest.NewRequest("GET", "/assets/app.js", nil) + recorder := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(recorder, req) + + // Check status code + assert.Equal(t, http.StatusOK, recorder.Code, "Should return status OK for JS file") + + // Check that the JS content is in the response + assert.Equal(t, "console.log('Hello, world!');", recorder.Body.String(), "Response should contain the JS content") + }) +}