package esm_test import ( "context" "os" "path/filepath" "testing" "git.fsdpf.net/go/jscriptx/esm" "github.com/evanw/esbuild/pkg/api" ) // 推荐的项目布局:配置和依赖放在项目根,源码放在 src/,加载器指向 src/。 // // app/ // ├── package.json ← npm 配置 // ├── tsconfig.json ← esbuild 会自动往上找到并生效 // ├── node_modules/ ← 依赖 // └── src/ ← NewLoader 指这里,寻址不带前缀 // ├── PkgVersion/ // └── Resource/ func TestLayout_src布局(t *testing.T) { root := t.TempDir() app := filepath.Join(root, "app") w := func(p, s string) { t.Helper() if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(s), 0o644); err != nil { t.Fatal(err) } } w(filepath.Join(app, "package.json"), `{"name":"fsdpf-app","type":"module"}`) w(filepath.Join(app, "tsconfig.json"), `{ "compilerOptions": { "baseUrl": ".", "paths": { "@lib/*": ["./src/lib/*"] }, "experimentalDecorators": true } }`) w(filepath.Join(app, "node_modules", "tinylib", "package.json"), `{"name":"tinylib","main":"index.js"}`) w(filepath.Join(app, "node_modules", "tinylib", "index.js"), `export function hi() { return "nm" }`) w(filepath.Join(app, "src", "lib", "helper.ts"), `export function help(): string { return "helper" }`) // 依赖 + tsconfig 的 paths 别名 w(filepath.Join(app, "src", "Resource", "ResCreateController.ts"), `import { hi } from "tinylib" import { help } from "@lib/helper" export default class ResCreateController { Get(): string { return hi() + "/" + help() } }`) // tsconfig 里开的 experimentalDecorators w(filepath.Join(app, "src", "Decor", "DecorController.ts"), `function Route(path: string) { return function (target: any, key: string, desc: PropertyDescriptor) { const orig = desc.value desc.value = function (...args: any[]) { return path + ":" + orig.apply(this, args) } return desc } } export default class DecorController { @Route("/api") Get(): string { return "decorated" } }`) src := filepath.Join(app, "src") // 加载器指向 app 时,源码深了一层,默认 glob 匹配不到 if l, err := esm.NewLoader(app); err == nil && len(l.Names()) != 0 { t.Errorf("默认 glob 只认第一层子目录,不该匹配到 src 下的文件: %v", l.Names()) } loader, err := esm.NewLoader(src) if err != nil { t.Fatalf("NewLoader 失败: %v", err) } e := newEngineWith(t, loader) ctx := context.Background() for _, c := range []struct{ name, want string }{ // node_modules 在 src 的上层(app 下),逐级往上能找到; // tsconfig.json 同样在上层,paths 别名照样生效 {"Resource/ResCreateController", "nm/helper"}, // tsconfig 里的 experimentalDecorators 也生效 {"Decor/DecorController", "/api:decorated"}, } { obj, err := e.New(ctx, c.name) if err != nil { t.Errorf("%s: %v", c.name, err) continue } got, err := obj.Call(ctx, "Get") if err != nil { t.Errorf("%s: %v", c.name, err) } else if got != c.want { t.Errorf("%s = %#v, want %q", c.name, got, c.want) } obj.Close() } } // 想把辅助模块挡在入口之外,用 glob 收窄。 func TestLayout_用glob只收Controller(t *testing.T) { root := t.TempDir() w := func(p, s string) { t.Helper() os.MkdirAll(filepath.Dir(p), 0o755) if err := os.WriteFile(p, []byte(s), 0o644); err != nil { t.Fatal(err) } } w(filepath.Join(root, "Resource", "ResCreateController.ts"), `import { help } from "../lib/helper" export default class C { Get(): string { return help() } }`) w(filepath.Join(root, "lib", "helper.ts"), `export function help(): string { return "helper" }`) loader, err := esm.NewLoader(root, esm.WithGlobs("*/*Controller.ts")) if err != nil { t.Fatal(err) } names := loader.Names() if len(names) != 1 || names[0] != "Resource/ResCreateController" { t.Errorf("入口应该只有 Controller: %v", names) } // 挡在入口外不影响被 import e := newEngineWith(t, loader) obj, err := e.New(context.Background(), "Resource/ResCreateController") if err != nil { t.Fatal(err) } defer obj.Close() if got, _ := obj.Call(context.Background(), "Get"); got != "helper" { t.Errorf("got = %#v", got) } } // testdata 本身就是推荐布局,这里验证它的配置文件确实在起作用: // node_modules 和 tsconfig.json 都在 src 的上层,靠逐级往上查找生效。 func TestLayout_testdata走的就是推荐布局(t *testing.T) { e := newEngine(t, "testdata/app/src") ctx := context.Background() obj, err := e.New(ctx, "Resource/ResSchemaController") if err != nil { t.Fatal(err) } defer obj.Close() // tinylib / @fsdpf/util 来自 app/node_modules(src 的上层) got, err := obj.Call(ctx, "Describe", "widget", 12.5) if err != nil { t.Fatalf("node_modules 没生效: %v", err) } if got != "WIDGET ¥12.50 stored:s" { t.Errorf("Describe = %#v", got) } // @lib/format 是 app/tsconfig.json 里配的 paths 别名 got, err = obj.Call(ctx, "Tag", "x") if err != nil { t.Fatalf("tsconfig 的 paths 别名没生效: %v", err) } if got != "[x]" { t.Errorf("Tag = %#v", got) } } // WithTarget / WithDefine:打包时的语法降级和编译期常量替换。 func TestLayout_打包选项(t *testing.T) { root := t.TempDir() sub := filepath.Join(root, "Mod") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(sub, "C.ts"), []byte(`declare const __MODE__: string export default class C { Mode(): string { return __MODE__ } Pick(o: any): string { return o?.a?.b ?? "兜底" } }`), 0o644); err != nil { t.Fatal(err) } loader, err := esm.NewLoader(root, esm.WithDefine(map[string]string{"__MODE__": `"生产"`}), // 可选链和空值合并降级到 ES2015,goja 对新语法覆盖不全时的出路 esm.WithTarget(api.ES2015), ) if err != nil { t.Fatal(err) } e := newEngineWith(t, loader) ctx := context.Background() obj, err := e.New(ctx, "Mod/C") if err != nil { t.Fatal(err) } defer obj.Close() if got, _ := obj.Call(ctx, "Mode"); got != "生产" { t.Errorf("WithDefine 没生效, got %#v", got) } if got, err := obj.Call(ctx, "Pick", nil); err != nil || got != "兜底" { t.Errorf("降级后语法失效: got=%#v err=%v", got, err) } if got, _ := obj.Call(ctx, "Pick", map[string]any{"a": map[string]any{"b": "命中"}}); got != "命中" { t.Errorf("got = %#v", got) } }