package esm_test import ( "context" "errors" "os" "path/filepath" "strings" "testing" "time" "git.fsdpf.net/go/jscriptx" "git.fsdpf.net/go/jscriptx/esm" ) func newEngine(t *testing.T, dir string, opts ...esm.Option) *jscriptx.Engine { t.Helper() loader, err := esm.NewLoader(dir, opts...) if err != nil { t.Fatalf("NewLoader 失败: %v", err) } return newEngineWith(t, loader) } func newEngineWith(t *testing.T, loaders ...*esm.Loader) *jscriptx.Engine { t.Helper() layers := make([]jscriptx.Loader, len(loaders)) for i, l := range loaders { layers[i] = l } e, err := jscriptx.New( jscriptx.WithLogger(nil), jscriptx.WithLoader(layers...), jscriptx.WithAutoReload(true), ) if err != nil { t.Fatal(err) } t.Cleanup(e.Close) return e } // 核心场景:按 目录/文件 取到 class,实例化,调方法。 func TestLoadDir_按路径取Controller(t *testing.T) { e := newEngine(t, "testdata/app/src") ctx := context.Background() ctrl, err := e.New(ctx, "Resource/ResCreateController", "产品", "创建") if err != nil { t.Fatalf("取 controller 失败: %v", err) } defer ctrl.Close() got, err := ctrl.Call(ctx, "Init") if err != nil { t.Fatalf("调用 Init 失败: %v", err) } if got != "ResCreate:产品/创建" { t.Errorf("Init = %#v", got) } // import 进来的公共类能用,实例状态跨调用保持 for i := 1; i <= 3; i++ { got, err := ctrl.Call(ctx, "Store", "配置") if err != nil { t.Fatal(err) } want := "stored:配置#" + string(rune('0'+i)) if got != want { t.Errorf("第 %d 次 Store = %#v, want %q", i, got, want) } } } func TestLoadDir_JS和TS混用(t *testing.T) { e := newEngine(t, "testdata/app/src") ctx := context.Background() // .js 写的 controller ctrl, err := e.New(ctx, "PkgVersion/PkgImportController", "framework") if err != nil { t.Fatal(err) } defer ctrl.Close() if got, _ := ctrl.Call(ctx, "Init"); got != "PkgImport:framework" { t.Errorf("got = %#v", got) } if got, _ := ctrl.Call(ctx, "Handle"); got.(int64) != 1 { t.Errorf("got = %#v", got) } } // 只有命名导出、没有 default 的模块,整个模块当导出对象用。 func TestLoadDir_只有命名导出(t *testing.T) { e := newEngine(t, "testdata/app/src") ctx := context.Background() s, err := e.Script("PkgVersion/PkgOptionsController") if err != nil { t.Fatal(err) } got, err := s.Call(ctx, "Options") if err != nil { t.Fatalf("调用命名导出失败: %v", err) } if got != "opts" { t.Errorf("got = %#v", got) } if got, _ := s.Call(ctx, "Version"); got != "v1" { t.Errorf("got = %#v", got) } } // 入口列表:默认只有第一层子目录下的文件算入口。 func TestLoadDir_入口规则(t *testing.T) { loader, err := esm.NewLoader("testdata/app/src") if err != nil { t.Fatal(err) } names := loader.Names() want := map[string]bool{ "Async/AsyncController": true, "PkgVersion/PkgImportController": true, "PkgVersion/PkgOptionsController": true, "Resource/ResCreateController": true, "Resource/ResDeleteController": true, "Resource/ResSchemaController": true, "Resource/ResExecuter": true, "Resource/shared": true, "lib/format": true, "Shared/CartController": true, "Shared/OrderController": true, } if len(names) != len(want) { t.Errorf("入口数 = %d (%v), want %d", len(names), names, len(want)) } for _, n := range names { if !want[n] { t.Errorf("多了个入口: %s", n) } } // 自定义 glob:只要 *Controller loader2, err := esm.NewLoader("testdata/app/src", esm.WithGlobs("*/*Controller.js", "*/*Controller.ts")) if err != nil { t.Fatal(err) } for _, n := range loader2.Names() { if !strings.HasSuffix(n, "Controller") { t.Errorf("glob 没生效,混进了 %s", n) } } if len(loader2.Names()) != 8 { t.Errorf("Controller 数 = %d, want 8: %v", len(loader2.Names()), loader2.Names()) } } // 公共模块的状态不跨 controller 共享——这是必须让脚本作者知道的事。 func TestLoadDir_公共模块状态不共享(t *testing.T) { e := newEngine(t, "testdata/app/src") ctx := context.Background() a, err := e.New(ctx, "Resource/ResCreateController", "x", "y") if err != nil { t.Fatal(err) } defer a.Close() b, err := e.New(ctx, "Resource/ResDeleteController") if err != nil { t.Fatal(err) } defer b.Close() got1, _ := a.Call(ctx, "Seq") got2, _ := a.Call(ctx, "Seq") got3, _ := b.Call(ctx, "Seq") if got1 != "[create:1]" || got2 != "[create:2]" { t.Errorf("同一实例内应该累加: %v %v", got1, got2) } if got3 != "[delete:1]" { t.Errorf("另一个 controller 的公共模块状态应该独立,got %v", got3) } // 同一个 controller 的两个实例之间也不共享 a2, _ := e.New(ctx, "Resource/ResCreateController", "x", "y") defer a2.Close() if got, _ := a2.Call(ctx, "Seq"); got != "[create:1]" { t.Errorf("不同实例之间不该共享公共模块状态,got %v", got) } } // 错误要能定位回 .ts 源文件的行号。 func TestLoadDir_错误定位回源码(t *testing.T) { e := newEngine(t, "testdata/app/src") ctx := context.Background() ctrl, err := e.New(ctx, "Resource/ResCreateController", "x", "y") if err != nil { t.Fatal(err) } defer ctrl.Close() _, err = ctrl.Call(ctx, "Boom") if err == nil { t.Fatal("应该报错") } var jsErr *jscriptx.Error if !errors.As(err, &jsErr) { t.Fatalf("错误类型不对: %T", err) } if !strings.Contains(jsErr.Msg, "创建失败") { t.Errorf("没带上脚本的错误信息: %q", jsErr.Msg) } if len(jsErr.Stack) == 0 { t.Fatal("没有栈信息") } t.Logf("定位: %s:%d:%d", jsErr.Stack[0].Source, jsErr.Stack[0].Line, jsErr.Stack[0].Column) // Boom 在 ResCreateController.ts 的第 15 行(打包产物里不是这个位置, // 能对上说明 sourcemap 生效了) if jsErr.Stack[0].Line != 15 { t.Errorf("行号没映射回源码, got %d, want 15", jsErr.Stack[0].Line) } } // 改了 .ts 源文件,自动重新打包生效。 func TestLoadDir_热更新(t *testing.T) { dir := t.TempDir() sub := filepath.Join(dir, "Mod") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } helper := filepath.Join(sub, "helper.ts") ctrl := filepath.Join(sub, "MyController.ts") write := func(p, s string) { t.Helper() if err := os.WriteFile(p, []byte(s), 0o644); err != nil { t.Fatal(err) } } write(helper, `export function greet(): string { return "v1" }`) write(ctrl, `import { greet } from "./helper" export default class MyController { Get(): string { return greet() } }`) e := newEngine(t, dir) ctx := context.Background() obj, err := e.New(ctx, "Mod/MyController") if err != nil { t.Fatal(err) } if got, _ := obj.Call(ctx, "Get"); got != "v1" { t.Fatalf("got = %#v", got) } obj.Close() // 改的是被 import 的文件,不是 controller 本身——依赖变化也要能感知 time.Sleep(10 * time.Millisecond) write(helper, `export function greet(): string { return "v2" }`) obj2, err := e.New(ctx, "Mod/MyController") if err != nil { t.Fatalf("热更新后取实例失败: %v", err) } defer obj2.Close() got, err := obj2.Call(ctx, "Get") if err != nil { t.Fatal(err) } if got != "v2" { t.Errorf("改了被 import 的文件没生效, got %#v", got) } } // 新增文件后能被发现。 func TestLoadDir_新增文件(t *testing.T) { dir := t.TempDir() sub := filepath.Join(dir, "Mod") os.MkdirAll(sub, 0o755) os.WriteFile(filepath.Join(sub, "A.ts"), []byte(`export default class A { Hi() { return "a" } }`), 0o644) e := newEngine(t, dir) ctx := context.Background() if _, err := e.New(ctx, "Mod/B"); !errors.Is(err, jscriptx.ErrScriptNotFound) { t.Fatalf("还没建 B,应该报找不到, got %v", err) } os.WriteFile(filepath.Join(sub, "B.ts"), []byte(`export default class B { Hi() { return "b" } }`), 0o644) obj, err := e.New(ctx, "Mod/B") if err != nil { t.Fatalf("新增的文件没被发现: %v", err) } defer obj.Close() if got, _ := obj.Call(ctx, "Hi"); got != "b" { t.Errorf("got = %#v", got) } } // TypeScript 语法错误要在 NewLoader 就报出来,且带文件位置。 func TestLoadDir_语法错误(t *testing.T) { dir := t.TempDir() sub := filepath.Join(dir, "Mod") os.MkdirAll(sub, 0o755) os.WriteFile(filepath.Join(sub, "Bad.ts"), []byte("export default class { 语法错误 ((("), 0o644) _, err := esm.NewLoader(dir) if err == nil { t.Fatal("语法错误应该在 NewLoader 就报出来") } if !strings.Contains(err.Error(), "Bad.ts") { t.Errorf("报错没带文件名: %v", err) } t.Logf("%v", err) } // import 一个不存在的模块要报清楚。 func TestLoadDir_import不存在的模块(t *testing.T) { dir := t.TempDir() sub := filepath.Join(dir, "Mod") os.MkdirAll(sub, 0o755) // 必须真正用到 import 进来的东西,否则 esbuild 会 tree-shaking 掉, // 根本不需要解析那个模块 os.WriteFile(filepath.Join(sub, "A.ts"), []byte(`import { nope } from "./nope" export default class A { Hi() { return nope() } }`), 0o644) _, err := esm.NewLoader(dir) if err == nil { t.Fatal("应该报错") } t.Logf("%v", err) } func TestLoadDir_取不存在的入口(t *testing.T) { e := newEngine(t, "testdata/app/src") _, err := e.New(context.Background(), "Nope/NoController") if !errors.Is(err, jscriptx.ErrScriptNotFound) { t.Fatalf("want ErrScriptNotFound, got %v", err) } t.Logf("%v", err) }