fix(systemviews): surface bridge load failures
- commit
- 98c5923a5dddc601ef4fc8aec71574f57e84ab1f
- author
- brice · · 2026-04-24 01:51 UTC
- parent
- 8c27e69
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +122 -4
M assets/systemviews/systemviews.css +16 -0
| @@ -121,6 +121,22 @@ body { | |||
| 121 | 121 | color: var(--sv-muted, #9aa3b2); | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | .sv-error { | |
| 125 | + | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | |
| 126 | + | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | |
| 127 | + | border-radius: 0.75rem; | |
| 128 | + | color: var(--sv-fg, #e8ebf0); | |
| 129 | + | padding: 1rem; | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | .sv-error p { | |
| 133 | + | margin: 0; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | .sv-error p + p { | |
| 137 | + | margin-top: 0.5rem; | |
| 138 | + | } | |
| 139 | + | ||
| 124 | 140 | .sv-kv { | |
| 125 | 141 | margin: 0.75rem 0 0; | |
| 126 | 142 | display: grid; | |
| @@ -121,6 +121,22 @@ body { | |||||
| 121 | color: var(--sv-muted, #9aa3b2); | 121 | color: var(--sv-muted, #9aa3b2); | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | + | .sv-error { | |||
| 125 | + | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | |||
| 126 | + | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | |||
| 127 | + | border-radius: 0.75rem; | |||
| 128 | + | color: var(--sv-fg, #e8ebf0); | |||
| 129 | + | padding: 1rem; | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | .sv-error p { | |||
| 133 | + | margin: 0; | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | .sv-error p + p { | |||
| 137 | + | margin-top: 0.5rem; | |||
| 138 | + | } | |||
| 139 | + | ||||
| 124 | .sv-kv { | 140 | .sv-kv { | ||
| 125 | margin: 0.75rem 0 0; | 141 | margin: 0.75rem 0 0; | ||
| 126 | display: grid; | 142 | display: grid; | ||
M internal/infrastructure/systemviewsbridge/client.go +16 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "sync/atomic" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -29,6 +30,8 @@ var _ port.SystemviewFavoritesService = (*Client)(nil) | |||
| 29 | 30 | ||
| 30 | 31 | var requestSeq atomic.Uint64 | |
| 31 | 32 | ||
| 33 | + | var bridgeRequestTimeout = 15 * time.Second | |
| 34 | + | ||
| 32 | 35 | // NewClient creates a bridge client with native WebKit and fetch transports. | |
| 33 | 36 | func NewClient(native, fetch Transport) *Client { | |
| 34 | 37 | return &Client{native: native, fetch: fetch} | |
| @@ -284,6 +287,9 @@ func (c *Client) transport() Transport { | |||
| 284 | 287 | func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) { | |
| 285 | 288 | var zero T | |
| 286 | 289 | ||
| 290 | + | ctx, cancel := withBridgeRequestTimeout(ctx) | |
| 291 | + | defer cancel() | |
| 292 | + | ||
| 287 | 293 | raw, err := c.Send(ctx, msgType, payload) | |
| 288 | 294 | if err != nil { | |
| 289 | 295 | return zero, err | |
| @@ -292,6 +298,16 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any) | |||
| 292 | 298 | return decodeBridgeResponse[T](raw) | |
| 293 | 299 | } | |
| 294 | 300 | ||
| 301 | + | func withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) { | |
| 302 | + | if ctx == nil { | |
| 303 | + | ctx = context.Background() | |
| 304 | + | } | |
| 305 | + | if _, ok := ctx.Deadline(); ok || bridgeRequestTimeout <= 0 { | |
| 306 | + | return context.WithCancel(ctx) | |
| 307 | + | } | |
| 308 | + | return context.WithTimeout(ctx, bridgeRequestTimeout) | |
| 309 | + | } | |
| 310 | + | ||
| 295 | 311 | type bridgeResponse struct { | |
| 296 | 312 | RequestID string `json:"requestId"` | |
| 297 | 313 | Success bool `json:"success"` | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "sync/atomic" | 8 | "sync/atomic" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -29,6 +30,8 @@ var _ port.SystemviewFavoritesService = (*Client)(nil) | |||||
| 29 | 30 | ||||
| 30 | var requestSeq atomic.Uint64 | 31 | var requestSeq atomic.Uint64 | ||
| 31 | 32 | ||||
| 33 | + | var bridgeRequestTimeout = 15 * time.Second | |||
| 34 | + | ||||
| 32 | // NewClient creates a bridge client with native WebKit and fetch transports. | 35 | // NewClient creates a bridge client with native WebKit and fetch transports. | ||
| 33 | func NewClient(native, fetch Transport) *Client { | 36 | func NewClient(native, fetch Transport) *Client { | ||
| 34 | return &Client{native: native, fetch: fetch} | 37 | return &Client{native: native, fetch: fetch} | ||
| @@ -284,6 +287,9 @@ func (c *Client) transport() Transport { | |||||
| 284 | func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) { | 287 | func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) { | ||
| 285 | var zero T | 288 | var zero T | ||
| 286 | 289 | ||||
| 290 | + | ctx, cancel := withBridgeRequestTimeout(ctx) | |||
| 291 | + | defer cancel() | |||
| 292 | + | ||||
| 287 | raw, err := c.Send(ctx, msgType, payload) | 293 | raw, err := c.Send(ctx, msgType, payload) | ||
| 288 | if err != nil { | 294 | if err != nil { | ||
| 289 | return zero, err | 295 | return zero, err | ||
| @@ -292,6 +298,16 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any) | |||||
| 292 | return decodeBridgeResponse[T](raw) | 298 | return decodeBridgeResponse[T](raw) | ||
| 293 | } | 299 | } | ||
| 294 | 300 | ||||
| 301 | + | func withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) { | |||
| 302 | + | if ctx == nil { | |||
| 303 | + | ctx = context.Background() | |||
| 304 | + | } | |||
| 305 | + | if _, ok := ctx.Deadline(); ok || bridgeRequestTimeout <= 0 { | |||
| 306 | + | return context.WithCancel(ctx) | |||
| 307 | + | } | |||
| 308 | + | return context.WithTimeout(ctx, bridgeRequestTimeout) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 295 | type bridgeResponse struct { | 311 | type bridgeResponse struct { | ||
| 296 | RequestID string `json:"requestId"` | 312 | RequestID string `json:"requestId"` | ||
| 297 | Success bool `json:"success"` | 313 | Success bool `json:"success"` | ||
M internal/infrastructure/systemviewsbridge/client_test.go +32 -0
| @@ -3,7 +3,9 @@ package systemviewsbridge | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 11 | ) | |
| @@ -356,6 +358,23 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||
| 356 | 358 | } | |
| 357 | 359 | } | |
| 358 | 360 | ||
| 361 | + | func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) { | |
| 362 | + | oldTimeout := bridgeRequestTimeout | |
| 363 | + | bridgeRequestTimeout = time.Millisecond | |
| 364 | + | t.Cleanup(func() { bridgeRequestTimeout = oldTimeout }) | |
| 365 | + | ||
| 366 | + | transport := &blockingTransport{available: true} | |
| 367 | + | client := NewClient(transport, nil) | |
| 368 | + | ||
| 369 | + | _, err := client.Timeline(context.Background(), 25, 0) | |
| 370 | + | if !errors.Is(err, context.DeadlineExceeded) { | |
| 371 | + | t.Fatalf("Timeline() error = %v, want context deadline exceeded", err) | |
| 372 | + | } | |
| 373 | + | if !transport.called { | |
| 374 | + | t.Fatal("transport was not called") | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 359 | 378 | func TestClientCreateFolderIncludesParentID(t *testing.T) { | |
| 360 | 379 | t.Parallel() | |
| 361 | 380 | ||
| @@ -422,3 +441,16 @@ func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) { | |||
| 422 | 441 | t.last = append(t.last[:0], body...) | |
| 423 | 442 | return t.response, nil | |
| 424 | 443 | } | |
| 444 | + | ||
| 445 | + | type blockingTransport struct { | |
| 446 | + | available bool | |
| 447 | + | called bool | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func (t *blockingTransport) Available() bool { return t.available } | |
| 451 | + | ||
| 452 | + | func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) { | |
| 453 | + | t.called = true | |
| 454 | + | <-ctx.Done() | |
| 455 | + | return nil, ctx.Err() | |
| 456 | + | } | |
| @@ -3,7 +3,9 @@ package systemviewsbridge | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 6 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 7 | 9 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | ) | 11 | ) | ||
| @@ -356,6 +358,23 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||||
| 356 | } | 358 | } | ||
| 357 | } | 359 | } | ||
| 358 | 360 | ||||
| 361 | + | func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) { | |||
| 362 | + | oldTimeout := bridgeRequestTimeout | |||
| 363 | + | bridgeRequestTimeout = time.Millisecond | |||
| 364 | + | t.Cleanup(func() { bridgeRequestTimeout = oldTimeout }) | |||
| 365 | + | ||||
| 366 | + | transport := &blockingTransport{available: true} | |||
| 367 | + | client := NewClient(transport, nil) | |||
| 368 | + | ||||
| 369 | + | _, err := client.Timeline(context.Background(), 25, 0) | |||
| 370 | + | if !errors.Is(err, context.DeadlineExceeded) { | |||
| 371 | + | t.Fatalf("Timeline() error = %v, want context deadline exceeded", err) | |||
| 372 | + | } | |||
| 373 | + | if !transport.called { | |||
| 374 | + | t.Fatal("transport was not called") | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 359 | func TestClientCreateFolderIncludesParentID(t *testing.T) { | 378 | func TestClientCreateFolderIncludesParentID(t *testing.T) { | ||
| 360 | t.Parallel() | 379 | t.Parallel() | ||
| 361 | 380 | ||||
| @@ -422,3 +441,16 @@ func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) { | |||||
| 422 | t.last = append(t.last[:0], body...) | 441 | t.last = append(t.last[:0], body...) | ||
| 423 | return t.response, nil | 442 | return t.response, nil | ||
| 424 | } | 443 | } | ||
| 444 | + | ||||
| 445 | + | type blockingTransport struct { | |||
| 446 | + | available bool | |||
| 447 | + | called bool | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func (t *blockingTransport) Available() bool { return t.available } | |||
| 451 | + | ||||
| 452 | + | func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) { | |||
| 453 | + | t.called = true | |||
| 454 | + | <-ctx.Done() | |||
| 455 | + | return nil, ctx.Err() | |||
| 456 | + | } | |||
M internal/ui/systemviews/app.go +34 -3
| @@ -60,13 +60,14 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||
| 60 | 60 | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | var err error | |
| 63 | 64 | switch a.currentRoute { | |
| 64 | 65 | case RouteHistory: | |
| 65 | - | return a.loadHistoryRoute(ctx) | |
| 66 | + | err = a.loadHistoryRoute(ctx) | |
| 66 | 67 | case RouteFavorites: | |
| 67 | - | return a.loadFavoritesRoute(ctx) | |
| 68 | + | err = a.loadFavoritesRoute(ctx) | |
| 68 | 69 | case RouteConfig: | |
| 69 | - | return a.loadConfigRoute(ctx) | |
| 70 | + | err = a.loadConfigRoute(ctx) | |
| 70 | 71 | default: | |
| 71 | 72 | a.resetRouteState() | |
| 72 | 73 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| @@ -76,6 +77,36 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||
| 76 | 77 | }, a.shellTheme) | |
| 77 | 78 | return nil | |
| 78 | 79 | } | |
| 80 | + | if err != nil { | |
| 81 | + | a.renderRouteError(err) | |
| 82 | + | } | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (a *App) renderRouteError(err error) { | |
| 87 | + | a.resetRouteState() | |
| 88 | + | message := "unknown error" | |
| 89 | + | if err != nil { | |
| 90 | + | message = err.Error() | |
| 91 | + | } | |
| 92 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 93 | + | route: a.currentRoute, | |
| 94 | + | subtitle: routeSubtitle(a.currentRoute), | |
| 95 | + | body: errorStateHTML(message), | |
| 96 | + | }, a.shellTheme) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func routeSubtitle(route Route) string { | |
| 100 | + | switch route { | |
| 101 | + | case RouteHistory: | |
| 102 | + | return "Recent visits" | |
| 103 | + | case RouteFavorites: | |
| 104 | + | return "Saved bookmarks" | |
| 105 | + | case RouteConfig: | |
| 106 | + | return "Browser settings" | |
| 107 | + | default: | |
| 108 | + | return string(route) | |
| 109 | + | } | |
| 79 | 110 | } | |
| 80 | 111 | ||
| 81 | 112 | func (a *App) loadHistoryRoute(ctx context.Context) error { | |
| @@ -60,13 +60,14 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||||
| 60 | a.currentRoute = ParseRoute(a.deps.LocationURI) | 60 | a.currentRoute = ParseRoute(a.deps.LocationURI) | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | + | var err error | |||
| 63 | switch a.currentRoute { | 64 | switch a.currentRoute { | ||
| 64 | case RouteHistory: | 65 | case RouteHistory: | ||
| 65 | - | return a.loadHistoryRoute(ctx) | 66 | + | err = a.loadHistoryRoute(ctx) |
| 66 | case RouteFavorites: | 67 | case RouteFavorites: | ||
| 67 | - | return a.loadFavoritesRoute(ctx) | 68 | + | err = a.loadFavoritesRoute(ctx) |
| 68 | case RouteConfig: | 69 | case RouteConfig: | ||
| 69 | - | return a.loadConfigRoute(ctx) | 70 | + | err = a.loadConfigRoute(ctx) |
| 70 | default: | 71 | default: | ||
| 71 | a.resetRouteState() | 72 | a.resetRouteState() | ||
| 72 | a.renderedHTML = renderAppFrame(renderedPage{ | 73 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| @@ -76,6 +77,36 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||||
| 76 | }, a.shellTheme) | 77 | }, a.shellTheme) | ||
| 77 | return nil | 78 | return nil | ||
| 78 | } | 79 | } | ||
| 80 | + | if err != nil { | |||
| 81 | + | a.renderRouteError(err) | |||
| 82 | + | } | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (a *App) renderRouteError(err error) { | |||
| 87 | + | a.resetRouteState() | |||
| 88 | + | message := "unknown error" | |||
| 89 | + | if err != nil { | |||
| 90 | + | message = err.Error() | |||
| 91 | + | } | |||
| 92 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 93 | + | route: a.currentRoute, | |||
| 94 | + | subtitle: routeSubtitle(a.currentRoute), | |||
| 95 | + | body: errorStateHTML(message), | |||
| 96 | + | }, a.shellTheme) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func routeSubtitle(route Route) string { | |||
| 100 | + | switch route { | |||
| 101 | + | case RouteHistory: | |||
| 102 | + | return "Recent visits" | |||
| 103 | + | case RouteFavorites: | |||
| 104 | + | return "Saved bookmarks" | |||
| 105 | + | case RouteConfig: | |||
| 106 | + | return "Browser settings" | |||
| 107 | + | default: | |||
| 108 | + | return string(route) | |||
| 109 | + | } | |||
| 79 | } | 110 | } | ||
| 80 | 111 | ||||
| 81 | func (a *App) loadHistoryRoute(ctx context.Context) error { | 112 | func (a *App) loadHistoryRoute(ctx context.Context) error { | ||
M internal/ui/systemviews/app_test.go +20 -1
| @@ -2,6 +2,7 @@ package systemviews | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -84,6 +85,23 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||
| 84 | 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | |
| 85 | 86 | } | |
| 86 | 87 | ||
| 88 | + | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | |
| 89 | + | t.Parallel() | |
| 90 | + | ||
| 91 | + | history := &fakeHistoryService{err: errors.New("database unavailable")} | |
| 92 | + | app := NewApp(Dependencies{ | |
| 93 | + | History: history, | |
| 94 | + | LocationURI: "dumb://history", | |
| 95 | + | }) | |
| 96 | + | ||
| 97 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 98 | + | assert.Equal(t, RouteHistory, app.CurrentRoute()) | |
| 99 | + | assert.True(t, history.called) | |
| 100 | + | assert.Contains(t, app.renderedHTML, "Could not load this system view") | |
| 101 | + | assert.Contains(t, app.renderedHTML, "database unavailable") | |
| 102 | + | assert.Contains(t, app.renderedHTML, `role="alert"`) | |
| 103 | + | } | |
| 104 | + | ||
| 87 | 105 | func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) { | |
| 88 | 106 | t.Parallel() | |
| 89 | 107 | ||
| @@ -319,13 +337,14 @@ type fakeHistoryService struct { | |||
| 319 | 337 | limit int | |
| 320 | 338 | offset int | |
| 321 | 339 | entries []*entity.HistoryEntry | |
| 340 | + | err error | |
| 322 | 341 | } | |
| 323 | 342 | ||
| 324 | 343 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 325 | 344 | s.called = true | |
| 326 | 345 | s.limit = limit | |
| 327 | 346 | s.offset = offset | |
| 328 | - | return s.entries, nil | |
| 347 | + | return s.entries, s.err | |
| 329 | 348 | } | |
| 330 | 349 | ||
| 331 | 350 | func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | |
| @@ -2,6 +2,7 @@ package systemviews | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -84,6 +85,23 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||||
| 84 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | ||
| 85 | } | 86 | } | ||
| 86 | 87 | ||||
| 88 | + | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | |||
| 89 | + | t.Parallel() | |||
| 90 | + | ||||
| 91 | + | history := &fakeHistoryService{err: errors.New("database unavailable")} | |||
| 92 | + | app := NewApp(Dependencies{ | |||
| 93 | + | History: history, | |||
| 94 | + | LocationURI: "dumb://history", | |||
| 95 | + | }) | |||
| 96 | + | ||||
| 97 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 98 | + | assert.Equal(t, RouteHistory, app.CurrentRoute()) | |||
| 99 | + | assert.True(t, history.called) | |||
| 100 | + | assert.Contains(t, app.renderedHTML, "Could not load this system view") | |||
| 101 | + | assert.Contains(t, app.renderedHTML, "database unavailable") | |||
| 102 | + | assert.Contains(t, app.renderedHTML, `role="alert"`) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 87 | func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) { | 105 | func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) { | ||
| 88 | t.Parallel() | 106 | t.Parallel() | ||
| 89 | 107 | ||||
| @@ -319,13 +337,14 @@ type fakeHistoryService struct { | |||||
| 319 | limit int | 337 | limit int | ||
| 320 | offset int | 338 | offset int | ||
| 321 | entries []*entity.HistoryEntry | 339 | entries []*entity.HistoryEntry | ||
| 340 | + | err error | |||
| 322 | } | 341 | } | ||
| 323 | 342 | ||||
| 324 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 343 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 325 | s.called = true | 344 | s.called = true | ||
| 326 | s.limit = limit | 345 | s.limit = limit | ||
| 327 | s.offset = offset | 346 | s.offset = offset | ||
| 328 | - | return s.entries, nil | 347 | + | return s.entries, s.err |
| 329 | } | 348 | } | ||
| 330 | 349 | ||||
| 331 | func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | 350 | func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | ||
M internal/ui/systemviews/shell.go +4 -0
| @@ -109,3 +109,7 @@ func sectionHTML(class, title, inner string) string { | |||
| 109 | 109 | func emptyStateHTML(message string) string { | |
| 110 | 110 | return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message)) | |
| 111 | 111 | } | |
| 112 | + | ||
| 113 | + | func errorStateHTML(message string) string { | |
| 114 | + | return fmt.Sprintf(`<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, html.EscapeString(message)) | |
| 115 | + | } | |
| @@ -109,3 +109,7 @@ func sectionHTML(class, title, inner string) string { | |||||
| 109 | func emptyStateHTML(message string) string { | 109 | func emptyStateHTML(message string) string { | ||
| 110 | return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message)) | 110 | return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message)) | ||
| 111 | } | 111 | } | ||
| 112 | + | ||||
| 113 | + | func errorStateHTML(message string) string { | |||
| 114 | + | return fmt.Sprintf(`<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, html.EscapeString(message)) | |||
| 115 | + | } | |||