Files
seanime-docker/seanime-2.9.10/internal/extension_repo/goja_plugin_test.go
2025-09-20 14:08:38 +01:00

799 lines
19 KiB
Go

package extension_repo
import (
"fmt"
"net/http"
"net/http/httptest"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/continuity"
"seanime/internal/database/db"
"seanime/internal/events"
"seanime/internal/extension"
"seanime/internal/goja/goja_runtime"
"seanime/internal/hook"
"seanime/internal/library/fillermanager"
"seanime/internal/library/playbackmanager"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/mediaplayers/mpv"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/plugin"
"seanime/internal/test_utils"
"seanime/internal/util"
"seanime/internal/util/filecache"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
)
var (
testDocumentsDir = "/Users/rahim/Documents"
testDocumentCollectionDir = "/Users/rahim/Documents/collection"
testVideoPath = "/Users/rahim/Documents/collection/Bocchi the Rock/[ASW] Bocchi the Rock! - 01 [1080p HEVC][EDC91675].mkv"
tempTestDir = "$TEMP/test"
)
// TestPluginOptions contains options for initializing a test plugin
type TestPluginOptions struct {
ID string
Payload string
Language extension.Language
Permissions extension.PluginPermissions
PoolSize int
SetupHooks bool
}
// DefaultTestPluginOptions returns default options for a test plugin
func DefaultTestPluginOptions() TestPluginOptions {
return TestPluginOptions{
ID: "dummy-plugin",
Payload: "",
Language: extension.LanguageJavascript,
Permissions: extension.PluginPermissions{},
PoolSize: 15,
SetupHooks: true,
}
}
// InitTestPlugin initializes a test plugin with the given options
func InitTestPlugin(t testing.TB, opts TestPluginOptions) (*GojaPlugin, *zerolog.Logger, *goja_runtime.Manager, *anilist_platform.AnilistPlatform, events.WSEventManagerInterface, error) {
if opts.SetupHooks {
test_utils.SetTwoLevelDeep()
if tPtr, ok := t.(*testing.T); ok {
test_utils.InitTestProvider(tPtr, test_utils.Anilist())
}
}
ext := &extension.Extension{
ID: opts.ID,
Payload: opts.Payload,
Language: opts.Language,
Plugin: &extension.PluginManifest{},
}
if len(opts.Permissions.Scopes) > 0 {
ext.Plugin = &extension.PluginManifest{
Permissions: opts.Permissions,
}
}
ext.Plugin.Permissions.Allow = opts.Permissions.Allow
logger := util.NewLogger()
wsEventManager := events.NewMockWSEventManager(logger)
anilistClient := anilist.NewMockAnilistClient()
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger).(*anilist_platform.AnilistPlatform)
// Initialize hook manager if needed
if opts.SetupHooks {
hm := hook.NewHookManager(hook.NewHookManagerOptions{Logger: logger})
hook.SetGlobalHookManager(hm)
}
manager := goja_runtime.NewManager(logger)
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
require.NoError(t, err)
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
Database: database,
AnilistPlatform: anilistPlatform,
WSEventManager: wsEventManager,
AnimeLibraryPaths: &[]string{},
PlaybackManager: &playbackmanager.PlaybackManager{},
})
plugin, _, err := NewGojaPlugin(ext, opts.Language, logger, manager, wsEventManager)
return plugin, logger, manager, anilistPlatform, wsEventManager, err
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginAnime(t *testing.T) {
payload := `
function init() {
$ui.register(async (ctx) => {
try {
console.log("Fetching anime entry");
console.log(typeof ctx.anime.getAnimeEntry)
ctx.anime.getAnimeEntry(21)
//ctx.anime.getAnimeEntry(21).then((anime) => {
// console.log("Anime", anime)
//}).catch((e) => {
// console.error("Error fetching anime entry", e)
//})
} catch (e) {
console.error("Error fetching anime entry", e)
}
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionAnilist,
extension.PluginPermissionDatabase,
},
}
logger := util.NewLogger()
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
require.NoError(t, err)
metadataProvider := metadata.NewProvider(&metadata.NewProviderImplOptions{
FileCacher: lo.Must(filecache.NewCacher(t.TempDir())),
Logger: logger,
})
fillerManager := fillermanager.New(&fillermanager.NewFillerManagerOptions{
Logger: logger,
DB: database,
})
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
Database: database,
MetadataProvider: metadataProvider,
FillerManager: fillerManager,
})
_, logger, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(3 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginMpv(t *testing.T) {
payload := fmt.Sprintf(`
function init() {
$ui.register(async (ctx) => {
console.log("Testing MPV");
await ctx.mpv.openAndPlay("%s")
const cancel = ctx.mpv.onEvent((event) => {
console.log("Event received", event)
})
ctx.setTimeout(() => {
const conn = ctx.mpv.getConnection()
if (conn) {
conn.call("set_property", "pause", true)
}
}, 3000)
ctx.setTimeout(async () => {
console.log("Cancelling event listener")
cancel()
await ctx.mpv.stop()
}, 5000)
});
}
`, testVideoPath)
playbackManager, _, err := getPlaybackManager(t)
require.NoError(t, err)
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
PlaybackManager: playbackManager,
})
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionPlayback,
},
}
_, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(8 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////
// Test that the plugin cannot access paths that are not allowed
// $os.readDir should throw an error
func TestGojaPluginPathNotAllowed(t *testing.T) {
payload := fmt.Sprintf(`
function init() {
$ui.register((ctx) => {
const tempDir = $os.tempDir();
console.log("Temp dir", tempDir);
const dirPath = "%s";
const entries = $os.readDir(dirPath);
});
}
`, testDocumentCollectionDir)
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionSystem,
},
Allow: extension.PluginAllowlist{
ReadPaths: []string{"$TEMP/*", testDocumentsDir},
WritePaths: []string{"$TEMP/*"},
},
}
_, _, manager, _, _, err := InitTestPlugin(t, opts)
require.Error(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
// Test that the plugin can play a video and listen to events
func TestGojaPluginPlaybackEvents(t *testing.T) {
payload := fmt.Sprintf(`
function init() {
$ui.register((ctx) => {
console.log("Testing Playback");
const cancel = ctx.playback.registerEventListener("mySubscriber", (event) => {
console.log("Event received", event)
})
ctx.playback.playUsingMediaPlayer("%s")
ctx.setTimeout(() => {
console.log("Cancelling event listener")
cancel()
}, 15000)
});
}
`, testVideoPath)
playbackManager, _, err := getPlaybackManager(t)
require.NoError(t, err)
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
PlaybackManager: playbackManager,
})
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionPlayback,
},
}
_, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(16 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////
// Tests that we can register hooks and the UI handler.
// Tests that the state updates correctly and effects run as expected.
// Tests that we can fetch data from an external source.
func TestGojaPluginUIAndHooks(t *testing.T) {
// Create a test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
time.Sleep(1000 * time.Millisecond)
fmt.Fprint(w, `{"test": "data"}`)
}))
defer server.Close()
payload := fmt.Sprintf(`
function init() {
$app.onGetAnime(async (e) => {
const url = "%s"
// const res = $await(fetch(url))
const res = await fetch(url)
const data = res.json()
console.log("fetched results in hook", data)
$store.set("data", data)
console.log("first hook fired");
e.next();
});
$app.onGetAnime(async (e) => {
console.log("results from first hook", $store.get("data"));
e.next();
});
$ui.register((ctx) => {
const url = "%s"
console.log("this is the start");
const count = ctx.state(0)
ctx.effect(async () => {
console.log("running effect that takes 1s")
ctx.setTimeout(() => {
console.log("1s elapsed since first effect called")
}, 1000)
const [a, b, c, d, e, f] = await Promise.all([
ctx.fetch("https://jsonplaceholder.typicode.com/todos/1"),
ctx.fetch("https://jsonplaceholder.typicode.com/todos/2"),
ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"),
ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"),
ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"),
ctx.fetch(url),
])
console.log("fetch results", a.json(), b.json(), c.json(), d.json(), e.json(), f.json())
}, [count])
ctx.effect(() => {
console.log("running effect that runs fast ran second")
}, [count])
count.set(p => p+1)
console.log("this is the end");
});
}
`, server.URL, server.URL)
opts := DefaultTestPluginOptions()
opts.Payload = payload
_, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
go func() {
time.Sleep(time.Second)
_, err := anilistPlatform.GetAnime(t.Context(), 178022)
if err != nil {
t.Errorf("GetAnime returned error: %v", err)
}
// _, err = anilistPlatform.GetAnime(177709)
// if err != nil {
// t.Errorf("GetAnime returned error: %v", err)
// }
}()
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(3 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginStore(t *testing.T) {
payload := `
function init() {
$app.onGetAnime((e) => {
$store.set("anime", e.anime);
$store.set("value", 42);
e.next();
});
$app.onGetAnime((e) => {
console.log("Hook 2, value", $store.get("value"));
console.log("Hook 2, value 2", $store.get("value2"));
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
m, err := anilistPlatform.GetAnime(t.Context(), 178022)
if err != nil {
t.Fatalf("GetAnime returned error: %v", err)
}
util.Spew(m.Title)
util.Spew(m.Synonyms)
m, err = anilistPlatform.GetAnime(t.Context(), 177709)
if err != nil {
t.Fatalf("GetAnime returned error: %v", err)
}
util.Spew(m.Title)
value := plugin.store.Get("value")
require.NotNil(t, value)
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginJsonFieldNames(t *testing.T) {
payload := `
function init() {
$app.onPreUpdateEntryProgress((e) => {
console.log("pre update entry progress", e)
$store.set("mediaId", e.mediaId);
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
err = anilistPlatform.UpdateEntryProgress(t.Context(), 178022, 1, lo.ToPtr(1))
if err != nil {
t.Fatalf("GetAnime returned error: %v", err)
}
mediaId := plugin.store.Get("mediaId")
require.NotNil(t, mediaId)
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginAnilistCustomQuery(t *testing.T) {
payload := `
function init() {
$ui.register((ctx) => {
const token = $database.anilist.getToken()
try {
const res = $anilist.customQuery({ query:` + "`" + `
query GetOnePiece {
Media(id: 21) {
title {
romaji
english
native
userPreferred
}
airingSchedule(perPage: 1, page: 1) {
nodes {
episode
airingAt
}
}
id
}
}
` + "`" + `, variables: {}}, token);
console.log("res", res)
} catch (e) {
console.error("Error fetching anime list", e);
}
});
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionAnilist,
extension.PluginPermissionAnilistToken,
extension.PluginPermissionDatabase,
},
}
plugin, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
_ = plugin
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginAnilistListAnime(t *testing.T) {
payload := `
function init() {
$ui.register((ctx) => {
try {
const res = $anilist.listRecentAnime(1, 15, undefined, undefined, undefined)
console.log("res", res)
} catch (e) {
console.error("Error fetching anime list", e)
}
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionAnilist,
},
}
plugin, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
_ = plugin
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginStorage(t *testing.T) {
payload := `
function init() {
$app.onGetAnime((e) => {
if ($storage.get("foo") !== "qux") {
throw new Error("foo should be qux")
}
$storage.set("foo", "anime")
console.log("foo", $storage.get("foo"))
$store.set("expectedValue4", "anime")
e.next();
});
$ui.register((ctx) => {
$storage.set("foo", "bar")
console.log("foo", $storage.get("foo"))
$store.set("expectedValue1", "bar")
ctx.setTimeout(() => {
console.log("foo", $storage.get("foo"))
$storage.set("foo", "baz")
console.log("foo", $storage.get("foo"))
$store.set("expectedValue2", "baz")
}, 1000)
ctx.setTimeout(() => {
console.log("foo", $storage.get("foo"))
$storage.set("foo", "qux")
console.log("foo", $storage.get("foo"))
$store.set("expectedValue3", "qux")
}, 1500)
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionDatabase,
extension.PluginPermissionStorage,
},
}
plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
_ = plugin
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(2 * time.Second)
_, err = anilistPlatform.GetAnime(t.Context(), 178022)
require.NoError(t, err)
expectedValue1 := plugin.store.Get("expectedValue1")
require.Equal(t, "bar", expectedValue1)
expectedValue2 := plugin.store.Get("expectedValue2")
require.Equal(t, "baz", expectedValue2)
expectedValue3 := plugin.store.Get("expectedValue3")
require.Equal(t, "qux", expectedValue3)
expectedValue4 := plugin.store.Get("expectedValue4")
require.Equal(t, "anime", expectedValue4)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginTryCatch(t *testing.T) {
payload := `
function init() {
$ui.register((ctx) => {
try {
throw new Error("test error")
} catch (e) {
console.log("catch", e)
$store.set("error", e)
}
try {
undefined.f()
} catch (e) {
console.log("catch 2", e)
$store.set("error2", e)
}
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
plugin, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
err1 := plugin.store.Get("error")
require.NotNil(t, err1)
err2 := plugin.store.Get("error2")
require.NotNil(t, err2)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaSharedMemory(t *testing.T) {
payload := `
function init() {
$ui.register((ctx) => {
const state = ctx.state("test")
$store.set("state", state)
})
$app.onGetAnime((e) => {
const state = $store.get("state")
console.log("state", state)
console.log("state value", state.get())
e.next();
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
_ = plugin
manager.PrintPluginPoolMetrics(opts.ID)
_, err = anilistPlatform.GetAnime(t.Context(), 178022)
require.NoError(t, err)
time.Sleep(2 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////s
func getPlaybackManager(t *testing.T) (*playbackmanager.PlaybackManager, *anilist.AnimeCollection, error) {
logger := util.NewLogger()
wsEventManager := events.NewMockWSEventManager(logger)
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
if err != nil {
t.Fatalf("error while creating database, %v", err)
}
filecacher, err := filecache.NewCacher(t.TempDir())
require.NoError(t, err)
anilistClient := anilist.TestGetMockAnilistClient()
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), true)
metadataProvider := metadata.GetMockProvider(t)
require.NoError(t, err)
continuityManager := continuity.NewManager(&continuity.NewManagerOptions{
FileCacher: filecacher,
Logger: logger,
Database: database,
})
playbackManager := playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{
WSEventManager: wsEventManager,
Logger: logger,
Platform: anilistPlatform,
MetadataProvider: metadataProvider,
Database: database,
RefreshAnimeCollectionFunc: func() {
// Do nothing
},
DiscordPresence: nil,
IsOffline: lo.ToPtr(false),
ContinuityManager: continuityManager,
})
playbackManager.SetAnimeCollection(animeCollection)
playbackManager.SetSettings(&playbackmanager.Settings{
AutoPlayNextEpisode: false,
})
playbackManager.SetMediaPlayerRepository(mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{
Mpv: mpv.New(logger, "", ""),
Logger: logger,
Default: "mpv",
ContinuityManager: continuityManager,
}))
return playbackManager, animeCollection, nil
}