Files
gopeed/pkg/download/engine/engine_test.go
T

704 lines
17 KiB
Go

package engine
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util"
"github.com/dop251/goja"
)
func TestPolyfill(t *testing.T) {
doTestPolyfill(t, "MessageError")
doTestPolyfill(t, "XMLHttpRequest")
doTestPolyfill(t, "Blob")
doTestPolyfill(t, "Event")
doTestPolyfill(t, "EventTarget")
doTestPolyfill(t, "FormData")
doTestPolyfill(t, "AbortController")
doTestPolyfill(t, "AbortSignal")
doTestPolyfill(t, "TextDecoder")
doTestPolyfill(t, "TextEncoder")
doTestPolyfill(t, "fetch")
doTestPolyfill(t, "__gopeed_create_vm")
}
func TestError(t *testing.T) {
engine := NewEngine(nil)
_, err := engine.RunString(`
throw new MessageError('test');
`)
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {
t.Fatalf("expect MessageError, but got %v", me)
}
}
func TestCallFunction_DetachedAsyncWorkDoesNotBlockReturn(t *testing.T) {
engine := NewEngine(nil)
defer engine.Close()
if _, err := engine.RunString(`
globalThis.__done = false;
async function testDetached() {
(async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
globalThis.__done = true;
})();
return "ok";
}
`); err != nil {
t.Fatal(err)
}
fn, ok := goja.AssertFunction(engine.Runtime.Get("testDetached"))
if !ok {
t.Fatal("testDetached is not callable")
}
startedAt := time.Now()
value, err := engine.CallFunction(fn)
if err != nil {
t.Fatal(err)
}
elapsed := time.Since(startedAt)
if elapsed > 200*time.Millisecond {
t.Fatalf("detached async work blocked return for %s", elapsed)
}
if value != "ok" {
t.Fatalf("unexpected return value: %#v", value)
}
}
func TestCallFunction_AwaitedPromiseBlocksUntilSettled(t *testing.T) {
engine := NewEngine(nil)
defer engine.Close()
if _, err := engine.RunString(`
async function testAwaited() {
await new Promise((resolve) => setTimeout(resolve, 120));
return "ok";
}
`); err != nil {
t.Fatal(err)
}
fn, ok := goja.AssertFunction(engine.Runtime.Get("testAwaited"))
if !ok {
t.Fatal("testAwaited is not callable")
}
startedAt := time.Now()
value, err := engine.CallFunction(fn)
if err != nil {
t.Fatal(err)
}
elapsed := time.Since(startedAt)
if elapsed < 100*time.Millisecond {
t.Fatalf("awaited promise returned too early: %s", elapsed)
}
if value != "ok" {
t.Fatalf("unexpected return value: %#v", value)
}
}
func TestFetch(t *testing.T) {
server := startServer()
defer server.Close()
engine := NewEngine(nil)
if _, err := engine.RunString(fmt.Sprintf("var host = 'http://%s';", server.Addr().String())); err != nil {
t.Fatal(err)
}
_, err := engine.RunString(`
async function testGet(){
const resp = await fetch(host+'/get');
return resp.status;
}
async function testText(){
const resp = await fetch(host+'/text',{
method: 'POST',
body: 'test'
});
return await resp.text();
}
async function testResponseBodyGetReader() {
const resp = await fetch(host+'/text', {
method: 'POST',
body: 'stream-body'
});
if (!resp.body || typeof resp.body.getReader !== 'function') {
throw new Error('response.body.getReader is not available');
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
result += decoder.decode(value, { stream: true });
}
result += decoder.decode();
return result;
}
async function testStreamingFetchResponseBody() {
const startedAt = Date.now();
const resp = await fetch(host+'/stream');
const resolvedAt = Date.now() - startedAt;
const reader = resp.body.getReader();
const decoder = new TextDecoder();
const first = await reader.read();
const firstAt = Date.now() - startedAt;
const second = await reader.read();
const secondAt = Date.now() - startedAt;
let result = '';
result += decoder.decode(first.value, { stream: true });
result += decoder.decode(second.value, { stream: true });
result += decoder.decode();
return {
resolvedAt,
firstAt,
secondAt,
text: result
};
}
async function testOctetStream(file){
const resp = await fetch(host+'/octetStream',{
method: 'POST',
body: file
});
return await resp.text();
}
async function testRedirect() {
const url = host + '/redirect?num=3'
return await new Promise((resolve, reject) => {
fetch(url, {
method: 'HEAD',
redirect: 'error',
}).then(()=>reject())
fetch(url, {
method: 'HEAD',
redirect: 'follow',
}).then((res) =>res.headers.has('location') && reject()).catch(() => reject())
fetch(url, {
method: 'HEAD',
redirect: 'manual',
}).then((res) => {
const location = res.headers.get('location');
location ? resolve(location) : reject()
}).catch(() => reject())
})
}
async function testResponseUrl() {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', host+'/redirect?num=3');
xhr.onload = function(){
if (xhr.responseURL.includes('/redirect?num=0')){
resolve();
}else{
reject();
}
};
xhr.send();
});
}
async function testFormData(file){
const formData = new FormData();
formData.append('name', 'test');
formData.append('f', file);
const resp = await fetch(host+'/formData',{
method: 'POST',
body: formData
});
return await resp.json();
}
function testHeader(){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', host+'/header');
xhr.setRequestHeader('X-Gopeed-Test', 'test1');
xhr.setRequestHeader('x-gopeed-test', 'test2');
xhr.setRequestHeader('x-Gopeed-test', 'test3');
xhr.onload = function(){
const testHeader1 = xhr.getResponseHeader("X-Gopeed-Test");
const testHeader2 = xhr.getResponseHeader("x-gopeed-test");
const testHeader3 = xhr.getResponseHeader("x-Gopeed-test");
const expect = 'test1, test2, test3';
const all = xhr.getAllResponseHeaders();
if(testHeader1 === expect && testHeader2 === expect && testHeader3 === expect
&& all.includes('X-Gopeed-Test: '+expect)){
resolve();
}else{
reject();
}
};
xhr.send();
});
}
function testProgress(){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', host+'/get');
const xhrUploadPromise = new Promise((resolve, reject) => {
xhr.upload.onprogress = function(e){
if(e.loaded === e.total){
resolve();
}
}
});
const xhrPromise = new Promise((resolve, reject) => {
xhr.onprogress = function(e){
if(e.loaded === e.total){
resolve();
}
}
});
Promise.all([xhrUploadPromise, xhrPromise]).then(() => {
resolve();
});
xhr.send();
setTimeout(() => {
reject('timeout');
}, 1000);
});
}
function testAbort(){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', host+'/timeout?duration=500');
xhr.onabort = function() {
resolve();
};
xhr.send();
setTimeout(() => {
xhr.abort();
}, 200);
setTimeout(() => {
reject('timeout');
}, 1000);
});
}
function testTimeout(){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const t = 500;
xhr.open('GET', host+'/timeout?duration='+t);
xhr.timeout = t - 200;
xhr.onload = function() {
resolve();
};
xhr.ontimeout = function() {
reject('timeout');
};
xhr.send();
});
}
async function testFingerprint(fingerprint,ua){
__gopeed_setFingerprint(fingerprint);
const resp = await fetch(host+'/ua');
const data = await resp.json();
if(!data.user_agent.includes(ua)){
throw new Error('fingerprint test failed, user agent: ' + data.user_agent);
}
}
async function testFingerprintDefault(){
await testFingerprint('none', 'Go')
}
async function testFingerprintChrome(){
await testFingerprint('chrome', 'Chrome')
}
async function testFingerprintFirefox(){
await testFingerprint('firefox', 'Firefox')
}
async function testFingerprintSafari(){
await testFingerprint('safari', 'Safari')
}
`)
if err != nil {
t.Fatal(err)
}
result, err := callTestFun(engine, "testGet")
if err != nil {
t.Fatal(err)
}
if result != int64(200) {
t.Fatalf("testGet failed, want %d, got %d", 200, result)
}
result, err = callTestFun(engine, "testText")
if err != nil {
t.Fatal(err)
}
if result != "test" {
t.Fatalf("testText failed, want %s, got %s", "test", result)
}
result, err = callTestFun(engine, "testResponseBodyGetReader")
if err != nil {
t.Fatal(err)
}
if result != "stream-body" {
t.Fatalf("testResponseBodyGetReader failed, want %s, got %s", "stream-body", result)
}
result, err = callTestFun(engine, "testStreamingFetchResponseBody")
if err != nil {
t.Fatal(err)
}
streaming := result.(map[string]any)
if streaming["text"] != "chunk-1chunk-2" {
t.Fatalf("testStreamingFetchResponseBody failed, want %s, got %v", "chunk-1chunk-2", streaming["text"])
}
if int(streaming["resolvedAt"].(int64)) >= 120 {
t.Fatalf("fetch resolved too late, got %dms", streaming["resolvedAt"].(int64))
}
if int(streaming["firstAt"].(int64)) >= 120 {
t.Fatalf("first chunk arrived too late, got %dms", streaming["firstAt"].(int64))
}
if int(streaming["secondAt"].(int64)) < 120 {
t.Fatalf("second chunk arrived too early, got %dms", streaming["secondAt"].(int64))
}
func() {
jsFile, _, md5 := buildFile(t, engine.Runtime)
result, err = callTestFun(engine, "testOctetStream", jsFile)
if err != nil {
t.Fatal(err)
}
if result != md5 {
t.Fatalf("testOctetStream failed, want %s, got %s", md5, result)
}
}()
t.Run("testRedirect", func(t *testing.T) {
_, err := callTestFun(engine, "testRedirect")
if err != nil {
t.Fatal(err)
}
})
t.Run("testResponseUrl", func(t *testing.T) {
_, err = callTestFun(engine, "testResponseUrl")
if err != nil {
t.Fatal(err)
}
})
func() {
jsFile, goFile, md5 := buildFile(t, engine.Runtime)
result, err = callTestFun(engine, "testFormData", jsFile)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"name": "test",
"f": map[string]string{
"filename": goFile.Name,
"md5": md5,
},
}
if !test.JsonEqual(result, want) {
t.Fatalf("testFormData failed, want %v, got %v", want, result)
}
}()
_, err = callTestFun(engine, "testHeader")
if err != nil {
t.Fatal("header test failed", err)
}
_, err = callTestFun(engine, "testProgress")
if err != nil {
t.Fatal("progress test failed", err)
}
_, err = callTestFun(engine, "testAbort")
if err != nil {
t.Fatal("abort test failed", err)
}
_, err = callTestFun(engine, "testTimeout")
if err == nil || err.Error() != "timeout" {
t.Fatalf("timeout test failed, want %s, got %s", "timeout", err)
}
_, err = callTestFun(engine, "testFingerprintChrome")
if err != nil {
t.Fatal("testFingerprintChrome test failed", err)
}
_, err = callTestFun(engine, "testFingerprintFirefox")
if err != nil {
t.Fatal("testFingerprintFirefox test failed", err)
}
_, err = callTestFun(engine, "testFingerprintSafari")
if err != nil {
t.Fatal("testFingerprintSafari test failed", err)
}
}
func TestFetchWithProxy(t *testing.T) {
doTestFetchWithProxy(t, "", "")
doTestFetchWithProxy(t, "admin", "123")
}
func doTestFetchWithProxy(t *testing.T, usr, pwd string) {
httpListener := startServer()
defer httpListener.Close()
proxyListener := test.StartSocks5Server(usr, pwd)
defer proxyListener.Close()
engine := NewEngine(&Config{
ProxyConfig: &base.DownloaderProxyConfig{
Enable: true,
System: false,
Scheme: "socks5",
Host: proxyListener.Addr().String(),
Usr: usr,
Pwd: pwd,
},
})
if _, err := engine.RunString(fmt.Sprintf("var host = 'http://%s';", httpListener.Addr().String())); err != nil {
t.Fatal(err)
}
respCode, err := engine.RunString(`
(async function(){
const resp = await fetch(host+'/get');
return resp.status;
})()
`)
if err != nil {
t.Fatal(err)
}
if respCode != int64(200) {
t.Fatalf("fetch with proxy failed, want %d, got %d", 200, respCode)
}
}
func TestVm(t *testing.T) {
engine := NewEngine(nil)
value, err := engine.RunString(`
const vm = __gopeed_create_vm()
vm.set('a', 1)
vm.set('b', 2)
const result = vm.runString('a=a+1;b=b+1;a+b;')
const out = {
"a": vm.get('a'),
"b": vm.get('b'),
"result": result
}
out
`)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"a": 2,
"b": 3,
"result": 5,
}
if !test.JsonEqual(value, want) {
t.Fatalf("vm test failed, want %v, got %v", want, value)
}
}
func TestNonStopLoop(t *testing.T) {
engine := NewEngine(nil)
_, err := engine.RunString(`
function leak(){
setInterval(() => {
},500)
}
function test(){
leak()
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('done')
}, 1000)
})
}
`)
if err != nil {
t.Fatal(err)
}
val, err := callTestFun(engine, "test")
if err != nil {
panic(err)
}
if val != "done" {
t.Fatalf("infinite loop test failed, want %s, got %s", "done", val)
}
}
func doTestPolyfill(t *testing.T, module string) {
value, err := Run(fmt.Sprintf(`
!!globalThis['%s']
`, module))
if err != nil {
t.Fatal(err)
}
if !value.(bool) {
t.Fatalf("module %s not polyfilled", module)
}
}
func startServer() net.Listener {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
server := &http.Server{}
mux := http.NewServeMux()
mux.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/header", func(w http.ResponseWriter, r *http.Request) {
for k, v := range r.Header {
if strings.HasPrefix(k, "X-Gopeed") {
w.Header().Set(k, strings.Join(v, ", "))
}
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/text", func(w http.ResponseWriter, r *http.Request) {
buf, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
w.Write(buf)
})
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
_, _ = w.Write([]byte("chunk-1"))
flusher.Flush()
time.Sleep(150 * time.Millisecond)
_, _ = w.Write([]byte("chunk-2"))
flusher.Flush()
return
}
_, _ = w.Write([]byte("chunk-1chunk-2"))
})
mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) {
md5 := calcMd5(r.Body)
w.WriteHeader(http.StatusOK)
w.Write([]byte(md5))
})
mux.HandleFunc("/formData", func(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(1024 * 1024 * 30)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
result := make(map[string]any)
for k, v := range r.MultipartForm.Value {
result[k] = v[0]
}
for k, v := range r.MultipartForm.File {
f, _ := v[0].Open()
result[k] = map[string]string{
"filename": v[0].Filename,
"md5": calcMd5(f),
}
}
w.WriteHeader(http.StatusOK)
buf, _ := json.Marshal(result)
w.Write(buf)
})
mux.HandleFunc("/timeout", func(w http.ResponseWriter, r *http.Request) {
duration := r.URL.Query().Get("duration")
t, _ := strconv.Atoi(duration)
time.Sleep(time.Duration(t) * time.Millisecond)
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) {
num := r.URL.Query().Get("num")
n, _ := strconv.Atoi(num)
if n > 0 {
http.Redirect(w, r, fmt.Sprintf("/redirect?num=%d", n-1), http.StatusFound)
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
})
mux.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
data := map[string]any{
"user_agent": r.UserAgent(),
}
buf, _ := json.Marshal(data)
w.WriteHeader(http.StatusOK)
w.Write(buf)
})
server.Handler = mux
go server.Serve(listener)
return listener
}
func buildFile(t *testing.T, runtime *goja.Runtime) (goja.Value, *file.File, string) {
jsFile, err := file.NewJsFile(runtime)
if err != nil {
t.Fatal(err)
}
f := jsFile.Export().(*file.File)
data := "test"
f.Reader = strings.NewReader(data)
f.Name = "test.txt"
f.Size = int64(len(data))
return jsFile, f, calcMd5(strings.NewReader(data))
}
func callTestFun(engine *Engine, fun string, args ...any) (any, error) {
test, ok := goja.AssertFunction(engine.Runtime.Get(fun))
if !ok {
return nil, errors.New("function not found:" + fun)
}
return engine.CallFunction(test, args...)
}
func calcMd5(reader io.Reader) string {
// Open a new hash interface to write to
hash := md5.New()
// Copy the file in the hash interface and check for any error
if _, err := io.Copy(hash, reader); err != nil {
return ""
}
return hex.EncodeToString(hash.Sum(nil))
}