Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions content/recipes/apps/upload-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<field>_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
Expand Down
17 changes: 17 additions & 0 deletions examples/upload-modes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<field>_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
Expand Down
213 changes: 142 additions & 71 deletions examples/upload-modes/upload-modes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading