Files
jscriptx/esm/esm_test.go
T
what 6ce9b483fd feat(esm): 按目录加载脚本、打包与热更新
把一个目录当脚本仓库:按路径寻址、esbuild 打包、内容变了自动重编。

  - Loader    扫目录建索引,Load(name) 给出打好包的源码和版本号
  - bundler   esbuild 的封装。ESM 格式而不是 IIFE——IIFE 会附带一整套
              CommonJS interop helper,每建一个 VM 都要重跑一遍
  - plugin    把扩展的 TS 模块变成可以 import 的虚拟模块,磁盘上没有文件
  - typings   把这些虚拟模块的类型按 node_modules 布局落盘,编辑器才认识

esbuild 原生实现了 Node 的模块解析,所以脚本能直接 import node_modules
里的第三方库。写出来的类型文件是 index.ts 而不是 index.d.ts:扩展给的是
真正的模块源码,里面可能带实现,声明文件里不允许有实现。

node_modules 不参与热更新的版本计算——依赖包是装出来的,改动总伴随显式的
安装动作,而真实的 npm 包动辄上千个文件,每次取脚本 stat 一遍太贵。
2026-09-05 22:13:57 +08:00

346 lines
9.3 KiB
Go

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)
}