From 5485cfe4d45f4bea8154a7029e8d914cbf8bca32 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Fri, 12 Jun 2026 23:01:55 +0000 Subject: [PATCH 1/2] examples(upload-modes): WS-disabled e2e for Direct + Volume (#448, #449) Add TestUploadModes_DirectWSDisabled_E2E and TestUploadModes_VolumeWSDisabled_E2E, mirroring the existing Proxied WS-disabled test (stub a dead window.WebSocket to force the HTTP transport). Direct proves the HTTP upload_complete handshake runs the completion action (#448); Volume proves the multipart fallback stages to Dir, retained (#449). Refactor the shared chromium/app/skip setup into helpers and hoist the killWS stub to a package const. Document the WS-disabled behavior in the README and recipe. Tests stay gated on LVT_UPLOAD_MODES_E2E + LVT_LOCAL_CLIENT (verified locally against the unreleased server/client; CI-skipped per docs#67). Co-Authored-By: Claude Opus 4.8 (1M context) --- content/recipes/apps/upload-modes.md | 11 ++ examples/upload-modes/README.md | 17 ++ examples/upload-modes/upload-modes_test.go | 213 ++++++++++++++------- 3 files changed, 170 insertions(+), 71 deletions(-) diff --git a/content/recipes/apps/upload-modes.md b/content/recipes/apps/upload-modes.md index 3b09591..9d1daa6 100644 --- a/content/recipes/apps/upload-modes.md +++ b/content/recipes/apps/upload-modes.md @@ -106,6 +106,17 @@ The client fills the placeholder from a local `URL.createObjectURL` and never uploads the bytes. The server records a metadata-only entry (`entry.Preview == true`, no `TempPath` / `ExternalRef`). +## Works with the WebSocket disabled + +Every mode completes over plain HTTP when the socket is down. **Volume** falls +back to a single multipart POST that the server stages to `Dir` +([#449](https://github.com/livetemplate/livetemplate/issues/449)); **Direct** +presigns over HTTP, the browser PUTs, then the client re-sends the entry metadata +over an HTTP completion handshake so `upload__complete` still runs +([#448](https://github.com/livetemplate/livetemplate/issues/448)). **Proxied** +and **Preview** are single requests and were already WS-independent. No app code +changes — the same controller works on either transport. + ## Run it ```bash diff --git a/examples/upload-modes/README.md b/examples/upload-modes/README.md index 6daad09..5fc151b 100644 --- a/examples/upload-modes/README.md +++ b/examples/upload-modes/README.md @@ -29,6 +29,23 @@ livetemplate.WithUpload("preview", livetemplate.UploadConfig{Mode: livetemplate. - **Preview** uses the `{{.lvt.UploadPreview "preview"}}` helper; the client fills it from a local `URL.createObjectURL` and never uploads the bytes. +## Works with the WebSocket disabled + +All four modes complete over plain HTTP when the socket is unavailable +(`WithWebSocketDisabled()`, a proxy that blocks WS, or a transient drop): + +- **Volume** falls back to a single multipart POST that the server stages to + `Dir` (retained), instead of WebSocket chunks + ([#449](https://github.com/livetemplate/livetemplate/issues/449)). +- **Direct** presigns over HTTP, the browser PUTs to storage, then the client + re-sends the entry metadata over an HTTP completion handshake so the + `upload__complete` action still runs + ([#448](https://github.com/livetemplate/livetemplate/issues/448)). +- **Proxied** and **Preview** are single self-contained requests, so they were + already WS-independent. + +The `*WSDisabled_E2E` tests stub a dead `window.WebSocket` to prove each path. + ## Run ```bash diff --git a/examples/upload-modes/upload-modes_test.go b/examples/upload-modes/upload-modes_test.go index 17e6a8c..39db48d 100644 --- a/examples/upload-modes/upload-modes_test.go +++ b/examples/upload-modes/upload-modes_test.go @@ -14,53 +14,83 @@ import ( "github.com/chromedp/chromedp" ) -// TestUploadModes_E2E drives all four modes in a real browser. It is gated on -// LVT_LOCAL_CLIENT (the path to a built client bundle with mode support) and a -// chromium binary, since it exercises unreleased client behaviour. -// -// LVT_LOCAL_CLIENT=../../../client/.worktrees/upload-modes/dist/livetemplate-client.browser.js \ -// go test ./examples/upload-modes/ -run E2E -v -func TestUploadModes_E2E(t *testing.T) { - // Opt-in: these drive a locally-installed Chromium via ExecAllocator and are - // verified locally. Wiring them into the docs Docker-chrome CI harness (which - // uses a remote allocator + in-browser file upload, and would need - // host.docker.internal for Direct's presigned PUT) is a follow-up tracked in - // livetemplate/docs#67, so they skip in the cross-repo CI. +// killWS stubs window.WebSocket with a socket that never opens, before any page +// script runs, so the client falls back to the HTTP transport — the WS-disabled +// path the *WSDisabled_E2E tests exercise. +const killWS = ` + class DeadSocket { + constructor(url){ this.url=url; this.readyState=3; + setTimeout(()=>{ this.onerror&&this.onerror(new Event('error')); + this.onclose&&this.onclose(new CloseEvent('close',{code:1006,wasClean:false})); },0); } + send(){} close(){} + } + DeadSocket.CONNECTING=0; DeadSocket.OPEN=1; DeadSocket.CLOSING=2; DeadSocket.CLOSED=3; + window.WebSocket = DeadSocket;` + +// requireUploadModesE2E skips unless the opt-in env vars are set. These tests +// drive a locally-installed Chromium via ExecAllocator and exercise unreleased +// client behaviour, so they're verified locally; wiring them into the docs +// Docker-chrome CI harness is tracked in livetemplate/docs#67. +func requireUploadModesE2E(t *testing.T) { + t.Helper() if os.Getenv("LVT_UPLOAD_MODES_E2E") == "" || os.Getenv("LVT_LOCAL_CLIENT") == "" { t.Skip("set LVT_UPLOAD_MODES_E2E=1 and LVT_LOCAL_CLIENT to run the browser e2e (docs#67)") } +} +// newChromiumCtx starts a local Chromium via ExecAllocator and returns a chromedp +// context with a 60s deadline; all cancels are registered on t.Cleanup. +func newChromiumCtx(t *testing.T) context.Context { + t.Helper() + execPath := os.Getenv("LVT_CHROME") + if execPath == "" { + execPath = "chromium" + } + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.ExecPath(execPath), chromedp.NoSandbox) + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...) + t.Cleanup(cancelAlloc) + ctx, cancelCtx := chromedp.NewContext(allocCtx) + t.Cleanup(cancelCtx) + ctx, cancelTimeout := context.WithTimeout(ctx, 60*time.Second) + t.Cleanup(cancelTimeout) + return ctx +} + +// newUploadModesApp clears the storage dirs, starts the example server, and +// returns it with its base URL wired into the controller's presigner. +func newUploadModesApp(t *testing.T) (*httptest.Server, *UploadModesController) { + t.Helper() _ = os.RemoveAll("storage") _ = os.RemoveAll(".uploads") t.Cleanup(func() { _ = os.RemoveAll("storage") _ = os.RemoveAll(".uploads") }) - ctrl := &UploadModesController{} srv := httptest.NewServer(newApp(ctrl)) - defer srv.Close() + t.Cleanup(srv.Close) ctrl.baseURL = srv.URL + return srv, ctrl +} + +// TestUploadModes_E2E drives all four modes in a real browser. It is gated on +// LVT_LOCAL_CLIENT (the path to a built client bundle with mode support) and a +// chromium binary, since it exercises unreleased client behaviour. +// +// LVT_LOCAL_CLIENT=../../../client/.worktrees/upload-modes/dist/livetemplate-client.browser.js \ +// go test ./examples/upload-modes/ -run E2E -v +func TestUploadModes_E2E(t *testing.T) { + requireUploadModesE2E(t) + + srv, _ := newUploadModesApp(t) img, err := filepath.Abs("testdata.png") if err != nil { t.Fatalf("abs path: %v", err) } - execPath := os.Getenv("LVT_CHROME") - if execPath == "" { - execPath = "chromium" - } - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.ExecPath(execPath), - chromedp.NoSandbox, - ) - allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...) - defer cancelAlloc() - ctx, cancelCtx := chromedp.NewContext(allocCtx) - defer cancelCtx() - ctx, cancelTimeout := context.WithTimeout(ctx, 60*time.Second) - defer cancelTimeout() + ctx := newChromiumCtx(t) // Surface browser console output in the test log for debugging. chromedp.ListenTarget(ctx, func(ev interface{}) { @@ -140,56 +170,16 @@ func TestUploadModes_E2E(t *testing.T) { // the HTTP transport, so the upload_start handshake and the multipart POST both // go over HTTP. Same gating as the main e2e. func TestUploadModes_ProxiedWSDisabled_E2E(t *testing.T) { - // Opt-in: these drive a locally-installed Chromium via ExecAllocator and are - // verified locally. Wiring them into the docs Docker-chrome CI harness (which - // uses a remote allocator + in-browser file upload, and would need - // host.docker.internal for Direct's presigned PUT) is a follow-up tracked in - // livetemplate/docs#67, so they skip in the cross-repo CI. - if os.Getenv("LVT_UPLOAD_MODES_E2E") == "" || os.Getenv("LVT_LOCAL_CLIENT") == "" { - t.Skip("set LVT_UPLOAD_MODES_E2E=1 and LVT_LOCAL_CLIENT to run the browser e2e (docs#67)") - } - - _ = os.RemoveAll("storage") - _ = os.RemoveAll(".uploads") - t.Cleanup(func() { - _ = os.RemoveAll("storage") - _ = os.RemoveAll(".uploads") - }) + requireUploadModesE2E(t) - ctrl := &UploadModesController{} - srv := httptest.NewServer(newApp(ctrl)) - defer srv.Close() - ctrl.baseURL = srv.URL + srv, _ := newUploadModesApp(t) img, err := filepath.Abs("testdata.png") if err != nil { t.Fatalf("abs path: %v", err) } - execPath := os.Getenv("LVT_CHROME") - if execPath == "" { - execPath = "chromium" - } - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.ExecPath(execPath), chromedp.NoSandbox) - allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...) - defer cancelAlloc() - ctx, cancelCtx := chromedp.NewContext(allocCtx) - defer cancelCtx() - ctx, cancelTimeout := context.WithTimeout(ctx, 60*time.Second) - defer cancelTimeout() - - // Disable WebSocket before any page script runs: a socket that never opens - // makes the client fall back to the HTTP transport. - const killWS = ` - class DeadSocket { - constructor(url){ this.url=url; this.readyState=3; - setTimeout(()=>{ this.onerror&&this.onerror(new Event('error')); - this.onclose&&this.onclose(new CloseEvent('close',{code:1006,wasClean:false})); },0); } - send(){} close(){} - } - DeadSocket.CONNECTING=0; DeadSocket.OPEN=1; DeadSocket.CLOSING=2; DeadSocket.CLOSED=3; - window.WebSocket = DeadSocket;` + ctx := newChromiumCtx(t) var proxiedText string if err := chromedp.Run(ctx, @@ -218,6 +208,87 @@ func TestUploadModes_ProxiedWSDisabled_E2E(t *testing.T) { } } +// TestUploadModes_DirectWSDisabled_E2E proves Direct completes with the WebSocket +// unavailable (livetemplate#448): upload_start presigns over HTTP, the browser +// PUTs to the sink, then the client re-sends the entry metadata + ref over an +// HTTP upload_complete handshake so UploadDirectComplete runs and renders the ref. +func TestUploadModes_DirectWSDisabled_E2E(t *testing.T) { + requireUploadModesE2E(t) + + srv, _ := newUploadModesApp(t) + + img, err := filepath.Abs("testdata.png") + if err != nil { + t.Fatalf("abs path: %v", err) + } + + ctx := newChromiumCtx(t) + + var directText string + if err := chromedp.Run(ctx, + chromedp.ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(killWS).Do(ctx) + return err + }), + chromedp.Navigate(srv.URL), + chromedp.WaitVisible(`input[lvt-upload="direct"]`, chromedp.ByQuery), + chromedp.SetUploadFiles(`input[lvt-upload="direct"]`, []string{img}, chromedp.ByQuery), + // #direct-result only renders after the HTTP upload_complete handshake + // reconstructs the entry and UploadDirectComplete sets the ref. + chromedp.WaitVisible(`#direct-result`, chromedp.ByQuery), + chromedp.Text(`#direct-result`, &directText, chromedp.ByQuery), + ); err != nil { + t.Fatalf("direct flow (WS disabled): %v", err) + } + if !strings.Contains(directText, "/files/direct/testdata.png") { + t.Errorf("direct result = %q, want the stored ref", directText) + } + if _, err := os.Stat("storage/direct/testdata.png"); err != nil { + t.Errorf("direct bytes not PUT to the sink: %v", err) + } +} + +// TestUploadModes_VolumeWSDisabled_E2E proves Volume completes with the WebSocket +// unavailable (livetemplate#449): selecting the file falls back to a single +// multipart POST that the server stages to the field's Dir (retained), and +// UploadVolumeComplete renders the on-disk path. +func TestUploadModes_VolumeWSDisabled_E2E(t *testing.T) { + requireUploadModesE2E(t) + + srv, _ := newUploadModesApp(t) + + img, err := filepath.Abs("testdata.png") + if err != nil { + t.Fatalf("abs path: %v", err) + } + + ctx := newChromiumCtx(t) + + var volumeText string + if err := chromedp.Run(ctx, + chromedp.ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(killWS).Do(ctx) + return err + }), + chromedp.Navigate(srv.URL), + chromedp.WaitVisible(`input[lvt-upload="volume"]`, chromedp.ByQuery), + chromedp.SetUploadFiles(`input[lvt-upload="volume"]`, []string{img}, chromedp.ByQuery), + chromedp.WaitVisible(`#volume-result`, chromedp.ByQuery), + chromedp.Text(`#volume-result`, &volumeText, chromedp.ByQuery), + ); err != nil { + t.Fatalf("volume flow (WS disabled): %v", err) + } + // The retained path is under the configured Dir (storage/volume), not the + // ephemeral .uploads tree. + if !strings.Contains(volumeText, "storage/volume") { + t.Errorf("volume result = %q, want a path under the Dir storage/volume", volumeText) + } + matches, _ := filepath.Glob(filepath.Join("storage", "volume", "volume", "*")) + if len(matches) != 1 { + t.Errorf("expected exactly 1 retained file under storage/volume/volume, got %d", len(matches)) + } +} + // countFiles returns the number of regular files under root (0 if absent). func countFiles(t *testing.T, root string) int { t.Helper() From 22fcaf2255c6d708c187eb9d0f02fc31fdbb1834 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 13 Jun 2026 08:16:06 +0000 Subject: [PATCH 2/2] chore(deps): bump livetemplate to v0.14.0 for the WS-disabled upload fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.14.0 ships the server side of the upload-modes WS-disabled e2e (#448 Direct completion over HTTP, #449 Volume multipart→Dir), so the committed *WSDisabled_E2E tests now compile and run against the released fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 03e6946..49fcd57 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/go-playground/validator/v10 v10.30.2 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.13.0 + github.com/livetemplate/livetemplate v0.14.0 github.com/livetemplate/lvt v0.1.6 github.com/livetemplate/lvt/components v0.1.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index f447483..a8cd8f6 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/livetemplate/livetemplate v0.13.0 h1:ynuo/2ChwvEvU5EJW1fEF7zPBC6eN0DQBushrSpzhu8= -github.com/livetemplate/livetemplate v0.13.0/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.14.0 h1:9RoA4c3gLpqjzj0DFnUrBLj+zmlrhQtX8C7V7v8LD3k= +github.com/livetemplate/livetemplate v0.14.0/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= github.com/livetemplate/lvt v0.1.6 h1:1rDU5hDo+EtZ0mT+868wYD9czF2EHEgdacS4kpIUPQ4= github.com/livetemplate/lvt v0.1.6/go.mod h1:OrTdx3zvh0WeuugVueQoRG3ILRNJe/dThErxKsos6Rw= github.com/livetemplate/lvt/components v0.1.2 h1:MM2M5IZnsUAu0py9ZbtcQCo0bvUrL4Z3Ly/yDkYNyag=