mirror of
https://github.com/hanzoai/wasm.git
synced 2026-10-30 15:07:30 +00:00
rel trimmed the root with a prefix and never cleaned the name, so `import "/project/../outside"` reached Files as "../outside". Proven: the host was asked to stat and read ../outside.ts and the bundle inlined it, with no error and no diagnostic. Map cleans that name away, which is why the shipped tests were green. rel now cleans first and refuses what leaves the root, and it is the one way a string becomes a call on Files — a guest's import, a caller's Entry, the host's own Realpath answer. An on-load reads only a name some on-resolve answered for; a path in a request field is not authority. Diagnostics are one shape again: esbuild's column counted bytes where tsgo counts UTF-16 code units, so the same quote was 32 and 29, and a bare tsc diagnostic now names the file its own message names. A cancelled bundle reports context.Canceled instead of a truncated handshake. The guest's stderr is captured and capped rather than handed the host's own, the 5s wait for it to exit is the caller's context, and Register refuses a nil rather than dereferencing it at the first request. The tests fail without TSGO_WASM and ESBUILD_WASM: ok for a run that ran no compiler is worse than red. Their compilation cache belongs to the run. hostfs and hostspace are plugin and space. Co-authored-by: Hanzo Dev <[email protected]>
238 lines
7.6 KiB
Go
238 lines
7.6 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
package compile
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func tsgo(t *testing.T) *TSGo {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
c, err := NewTSGo(ctx, Config{Blob: blob(t, envTSGo, envTSGoSum), Cache: cacheDir})
|
|
if err != nil {
|
|
t.Fatalf("new tsgo: %v", err)
|
|
}
|
|
t.Cleanup(func() { c.Close(ctx) })
|
|
return c
|
|
}
|
|
|
|
func TestTSGoDetects(t *testing.T) {
|
|
c := (*TSGo)(nil)
|
|
if !c.Detects(project()) {
|
|
t.Fatal("a project with a tsconfig was not detected")
|
|
}
|
|
if !c.Detects(Project{Entry: []string{"a.ts"}, Files: Map{"a.ts": nil}}) {
|
|
t.Fatal("a TypeScript entry was not detected")
|
|
}
|
|
if c.Detects(Project{Entry: []string{"main.go"}, Files: Map{"main.go": nil}}) {
|
|
t.Fatal("a Go project was detected as TypeScript")
|
|
}
|
|
if c.Detects(Project{}) {
|
|
t.Fatal("a project serving no files was detected")
|
|
}
|
|
}
|
|
|
|
// The one diagnostic, and nothing else: file, 1-based line and column, code and
|
|
// severity all have to be right, because an agent acts on them without looking
|
|
// at the source.
|
|
func TestTSGoFindsTheTypeError(t *testing.T) {
|
|
c := tsgo(t)
|
|
diags, err := c.Check(context.Background(), project(), Options{})
|
|
if err != nil {
|
|
t.Fatalf("check: %v", err)
|
|
}
|
|
if len(diags) != 1 {
|
|
t.Fatalf("got %d diagnostics, want 1: %+v", len(diags), diags)
|
|
}
|
|
d := diags[0]
|
|
want := Diagnostic{
|
|
File: "entry.ts", Line: 2, Column: 14,
|
|
Severity: Error, Code: "TS2322", Checker: "tsgo",
|
|
}
|
|
if d.File != want.File || d.Line != want.Line || d.Column != want.Column ||
|
|
d.Severity != want.Severity || d.Code != want.Code || d.Checker != want.Checker {
|
|
t.Fatalf("diagnostic = %+v, want %+v (message %q)", d, want, d.Message)
|
|
}
|
|
if !strings.Contains(d.Message, "not assignable") {
|
|
t.Fatalf("message = %q", d.Message)
|
|
}
|
|
}
|
|
|
|
// A project that says nothing about libs gets es2022 and no ambient types, from
|
|
// a tsconfig that exists only in the guest's view of the project.
|
|
func TestTSGoSynthesizesAConfig(t *testing.T) {
|
|
c := tsgo(t)
|
|
p := project()
|
|
files := Map{}
|
|
for k, v := range p.Files.(Map) {
|
|
if k != "tsconfig.json" {
|
|
files[k] = v
|
|
}
|
|
}
|
|
p.Files = files
|
|
p.Entry = []string{"entry.ts", "math.ts"}
|
|
|
|
diags, err := c.Check(context.Background(), p, Options{})
|
|
if err != nil {
|
|
t.Fatalf("check: %v", err)
|
|
}
|
|
if len(diags) != 1 || diags[0].Code != "TS2322" {
|
|
t.Fatalf("diagnostics = %+v", diags)
|
|
}
|
|
if files.FileExists("tsconfig.json") {
|
|
t.Fatal("the synthesized config leaked into the project")
|
|
}
|
|
}
|
|
|
|
// A clean project is a clean answer, not an empty one that hides a crash.
|
|
func TestTSGoCleanProject(t *testing.T) {
|
|
c := tsgo(t)
|
|
p := project()
|
|
p.Files.(Map)["entry.ts"] = []byte("import { add } from \"./math\";\nexport const right: number = add(1, 2);\n")
|
|
|
|
diags, err := c.Check(context.Background(), p, Options{})
|
|
if err != nil {
|
|
t.Fatalf("check: %v", err)
|
|
}
|
|
if len(diags) != 0 {
|
|
t.Fatalf("diagnostics = %+v, want none", diags)
|
|
}
|
|
}
|
|
|
|
// Eight instances of one compiled module, at once, each with its own linear
|
|
// memory. This is the shape a session-per-agent host runs in.
|
|
func TestTSGoFleet(t *testing.T) {
|
|
const n = 8
|
|
c := tsgo(t)
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, n)
|
|
counts := make([]int, n)
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
diags, err := c.Check(context.Background(), project(), Options{})
|
|
errs[i], counts[i] = err, len(diags)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
for i := range errs {
|
|
if errs[i] != nil {
|
|
t.Fatalf("check %d: %v", i, errs[i])
|
|
}
|
|
if counts[i] != 1 {
|
|
t.Fatalf("check %d returned %d diagnostics, want 1", i, counts[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// A ceiling the linear memory cannot hold is refused before anything runs.
|
|
func TestTSGoRefusesMemoryAboveTheCap(t *testing.T) {
|
|
c := tsgo(t)
|
|
_, err := c.Check(context.Background(), project(), Options{Memory: 4096})
|
|
if err == nil {
|
|
t.Fatal("a 4 GiB ceiling was accepted under a 256 MiB cap")
|
|
}
|
|
if !strings.Contains(err.Error(), "host cap") {
|
|
t.Fatalf("error does not name the cap: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTSGoRefusesAnEmptyProject(t *testing.T) {
|
|
c := tsgo(t)
|
|
if _, err := c.Check(context.Background(), Project{}, Options{}); err == nil {
|
|
t.Fatal("a project serving no files was checked")
|
|
}
|
|
if _, err := c.Check(context.Background(), Project{Files: Map{}}, Options{}); err == nil {
|
|
t.Fatal("a project with no files and no config was checked")
|
|
}
|
|
}
|
|
|
|
func TestParseTSC(t *testing.T) {
|
|
out := "project/src/a.ts(3,17): error TS2322: Type 'number' is not assignable to type 'string'.\n" +
|
|
" The expected type comes from property 'x'.\n" +
|
|
"error TS5083: Cannot read file '/project/tsconfig.json'.\n" +
|
|
"src/b.ts(1,1): warning TS6133: 'x' is declared but never read.\n" +
|
|
"/etc/passwd.ts(1,1): error TS2322: Type 'number' is not assignable to type 'string'.\n"
|
|
diags := parseTSC(out, "/project")
|
|
if len(diags) != 4 {
|
|
t.Fatalf("got %d diagnostics: %+v", len(diags), diags)
|
|
}
|
|
if diags[0].File != "src/a.ts" || diags[0].Line != 3 || diags[0].Column != 17 || diags[0].Severity != Error {
|
|
t.Errorf("located = %+v", diags[0])
|
|
}
|
|
if !strings.HasSuffix(diags[0].Message, "property 'x'.") {
|
|
t.Errorf("continuation line was dropped: %q", diags[0].Message)
|
|
}
|
|
// A diagnostic with no position still names its file when the message does,
|
|
// because an agent routes by file and 0 is not a line.
|
|
if diags[1].Code != "TS5083" || diags[1].File != "tsconfig.json" || diags[1].Line != 0 || diags[1].Column != 0 {
|
|
t.Errorf("bare = %+v", diags[1])
|
|
}
|
|
if diags[2].Severity != Warning || diags[2].File != "src/b.ts" {
|
|
t.Errorf("warning = %+v", diags[2])
|
|
}
|
|
// A file the project does not serve gets no project-relative name rather
|
|
// than one that points an edit at the wrong file.
|
|
if diags[3].File != "" {
|
|
t.Errorf("a file outside the project was named %q", diags[3].File)
|
|
}
|
|
}
|
|
|
|
// Most quoted things in a tsc message are not files.
|
|
func TestNamedOnlyLiftsAFile(t *testing.T) {
|
|
for _, c := range []struct{ message, want string }{
|
|
{"File '/project/ghost.ts' not found.", "ghost.ts"},
|
|
{"Cannot read file '/project/tsconfig.json'.", "tsconfig.json"},
|
|
{"Argument for '--lib' option must be: 'es5', 'es2022'.", ""},
|
|
{"Cannot find type definition file for 'node'.", ""},
|
|
{"File '/etc/passwd.ts' not found.", ""},
|
|
{"Cannot find module './gone' or its corresponding type declarations.", ""},
|
|
} {
|
|
if got := named(c.message, "/project"); got != c.want {
|
|
t.Errorf("named(%q) = %q, want %q", c.message, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Options beat the project's own tsconfig, the way tsc's flags do: the same
|
|
// source that cannot find `document` under es2022 alone checks clean once dom
|
|
// is asked for.
|
|
func TestTSGoLibOverridesTheProject(t *testing.T) {
|
|
c := tsgo(t)
|
|
p := project()
|
|
p.Files.(Map)["entry.ts"] = []byte("export const title: string = document.title;\n")
|
|
p.Files.(Map)["tsconfig.json"] = []byte(`{"compilerOptions":{"strict":true,"noEmit":true,"target":"es2022","module":"esnext","moduleResolution":"bundler","lib":["es2022"],"types":[]},"files":["entry.ts"]}`)
|
|
|
|
diags, err := c.Check(context.Background(), p, Options{})
|
|
if err != nil {
|
|
t.Fatalf("check: %v", err)
|
|
}
|
|
if len(diags) != 1 || diags[0].Code != "TS2584" {
|
|
t.Fatalf("es2022 alone gave %+v, want one TS2584", diags)
|
|
}
|
|
|
|
diags, err = c.Check(context.Background(), p, Options{Lib: []string{"es2022", "dom"}})
|
|
if err != nil {
|
|
t.Fatalf("check with dom: %v", err)
|
|
}
|
|
if len(diags) != 0 {
|
|
t.Fatalf("with dom asked for: %+v, want none", diags)
|
|
}
|
|
}
|
|
|
|
// A cancelled context ends the check as a cancellation, not as a mystery exit
|
|
// code.
|
|
func TestTSGoRespectsCancellation(t *testing.T) {
|
|
c := tsgo(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := c.Check(ctx, project(), Options{}); err != context.Canceled {
|
|
t.Fatalf("err = %v, want context.Canceled", err)
|
|
}
|
|
}
|