mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 15:49:49 +00:00
272 lines
6.9 KiB
Go
272 lines
6.9 KiB
Go
package stream_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
|
|
)
|
|
|
|
type createdObjectURL struct {
|
|
opts *stream.ObjectURLOptions
|
|
open stream.ObjectURLOpener
|
|
}
|
|
|
|
func TestResponseBlobObjectURLReadsResponseStream(t *testing.T) {
|
|
const payload = "response blob payload"
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = io.WriteString(w, payload[:8])
|
|
w.(http.Flusher).Flush() // force an unknown-length/chunked response
|
|
_, _ = io.WriteString(w, payload[8:])
|
|
}))
|
|
defer server.Close()
|
|
|
|
created := make(chan createdObjectURL, 1)
|
|
engine, cleanup := newStreamTestEngine(t, created)
|
|
defer func() {
|
|
cleanup()
|
|
engine.Close()
|
|
}()
|
|
|
|
value, err := engine.RunString(fmt.Sprintf(`
|
|
(async () => {
|
|
const response = await fetch(%q);
|
|
const blob = await response.blob();
|
|
return __gopeed_blob_create_object_url(blob);
|
|
})()
|
|
`, server.URL))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value != "blob:test-1" {
|
|
t.Fatalf("unexpected object URL: %#v", value)
|
|
}
|
|
|
|
object := <-created
|
|
if object.opts.Size != int64(len(payload)) || object.opts.ContentType != "text/plain" {
|
|
t.Fatalf("unexpected object URL options: %#v", object.opts)
|
|
}
|
|
if !object.opts.Range {
|
|
t.Fatal("materialized response Blob should advertise range support")
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, readErr := io.ReadAll(reader)
|
|
_ = reader.Close()
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if got := string(data); got != payload {
|
|
t.Fatalf("unexpected response Blob data on open %d: %q", i+1, got)
|
|
}
|
|
}
|
|
rangeReader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 9, End: 12})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rangeData, readErr := io.ReadAll(rangeReader)
|
|
_ = rangeReader.Close()
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if got, want := string(rangeData), payload[9:13]; got != want {
|
|
t.Fatalf("unexpected materialized Blob range: got %q want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResponseBlobRejectsTruncatedResponse(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "64")
|
|
_, _ = io.WriteString(w, "partial")
|
|
}))
|
|
defer server.Close()
|
|
|
|
created := make(chan createdObjectURL, 1)
|
|
engine, cleanup := newStreamTestEngine(t, created)
|
|
defer func() {
|
|
cleanup()
|
|
engine.Close()
|
|
}()
|
|
|
|
value, err := engine.RunString(fmt.Sprintf(`
|
|
(async () => {
|
|
const response = await fetch(%q);
|
|
try {
|
|
await response.blob();
|
|
return "resolved";
|
|
} catch (_) {
|
|
return "rejected";
|
|
}
|
|
})()
|
|
`, server.URL))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value != "rejected" {
|
|
t.Fatalf("truncated response unexpectedly materialized a Blob: %#v", value)
|
|
}
|
|
}
|
|
|
|
func TestEmptyBlobObjectURL(t *testing.T) {
|
|
created := make(chan createdObjectURL, 1)
|
|
engine, cleanup := newStreamTestEngine(t, created)
|
|
defer func() {
|
|
cleanup()
|
|
engine.Close()
|
|
}()
|
|
|
|
value, err := engine.RunString(`
|
|
__gopeed_blob_create_object_url(new Blob([], { type: "application/empty" }))
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value != "blob:test-1" {
|
|
t.Fatalf("unexpected object URL: %#v", value)
|
|
}
|
|
|
|
object := <-created
|
|
if object.opts.Size != 0 || object.opts.Range {
|
|
t.Fatalf("unexpected empty Blob options: %#v", object.opts)
|
|
}
|
|
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer reader.Close()
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(data) != 0 {
|
|
t.Fatalf("expected empty Blob, got %q", data)
|
|
}
|
|
}
|
|
|
|
func TestBlobObjectURLReaderCloseCancelsPendingJSReader(t *testing.T) {
|
|
created := make(chan createdObjectURL, 1)
|
|
engine, cleanup := newStreamTestEngine(t, created)
|
|
defer func() {
|
|
cleanup()
|
|
engine.Close()
|
|
}()
|
|
|
|
value, err := engine.RunString(`
|
|
globalThis.__blobPipeCancelState = {
|
|
readCalls: 0,
|
|
cancelCalls: 0,
|
|
releaseCalls: 0,
|
|
cancelReason: "",
|
|
resolveRead: null,
|
|
};
|
|
const pendingSource = {
|
|
getReader() {
|
|
return {
|
|
read() {
|
|
__blobPipeCancelState.readCalls++;
|
|
return new Promise((resolve) => {
|
|
__blobPipeCancelState.resolveRead = resolve;
|
|
});
|
|
},
|
|
cancel(reason) {
|
|
__blobPipeCancelState.cancelCalls++;
|
|
__blobPipeCancelState.cancelReason = String(reason);
|
|
if (__blobPipeCancelState.resolveRead) {
|
|
__blobPipeCancelState.resolveRead({ done: true, value: undefined });
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
releaseLock() {
|
|
__blobPipeCancelState.releaseCalls++;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
__gopeed_blob_create_object_url(async () => pendingSource);
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value != "blob:test-1" {
|
|
t.Fatalf("unexpected object URL: %#v", value)
|
|
}
|
|
|
|
object := <-created
|
|
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
readDone := make(chan error, 1)
|
|
go func() {
|
|
_, readErr := reader.Read(make([]byte, 1))
|
|
readDone <- readErr
|
|
}()
|
|
|
|
waitForJSValue(t, engine, `String(__blobPipeCancelState.readCalls)`, "1")
|
|
if err := reader.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reader.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
select {
|
|
case readErr := <-readDone:
|
|
if !errors.Is(readErr, context.Canceled) {
|
|
t.Fatalf("unexpected pending read result: %v", readErr)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("pending Go read was not released by Close")
|
|
}
|
|
waitForJSValue(t, engine, `JSON.stringify({
|
|
cancelCalls: __blobPipeCancelState.cancelCalls,
|
|
releaseCalls: __blobPipeCancelState.releaseCalls,
|
|
cancelReason: __blobPipeCancelState.cancelReason,
|
|
})`, `{"cancelCalls":1,"releaseCalls":1,"cancelReason":"blob request closed"}`)
|
|
}
|
|
|
|
func waitForJSValue(t *testing.T, engine *engine.Engine, expression, want string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
var got any
|
|
for time.Now().Before(deadline) {
|
|
var err error
|
|
got, err = engine.RunString(expression)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got == want {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for JavaScript value %q, got %#v", want, got)
|
|
}
|
|
|
|
func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine.Engine, func()) {
|
|
t.Helper()
|
|
var next atomic.Int64
|
|
e := engine.NewEngine(&engine.Config{
|
|
StreamConfig: &stream.Config{
|
|
CreateObjectURL: func(opts *stream.ObjectURLOptions, open stream.ObjectURLOpener) (string, error) {
|
|
created <- createdObjectURL{opts: opts, open: open}
|
|
return fmt.Sprintf("blob:test-%d", next.Add(1)), nil
|
|
},
|
|
RevokeObjectURL: func(string) error { return nil },
|
|
},
|
|
})
|
|
return e, e.Close
|
|
}
|