Skip to content

Commit be436a8

Browse files
authored
feat: support WebAssembly unit tests (#359)
* fix: remove padding in output urls * feat: skip module cleanup on dev env * fix: deterministic hash keys for artifacts * feat: support test build for wasm * feat: split build response for v1 and v2 * feat: disable minimap by default * feat: pass -test.v flag for unit tests
1 parent 0a97c46 commit be436a8

File tree

17 files changed

+355
-44
lines changed

17 files changed

+355
-44
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export VITE_WASM_BASE_URL=/wasm
3636

3737
.PHONY:run
3838
run:
39-
@GOROOT=$(GOROOT) APP_CLEAN_INTERVAL=10h $(GO) run $(PKG) \
39+
@GOROOT=$(GOROOT) APP_SKIP_MOD_CLEANUP=true $(GO) run $(PKG) \
4040
-f ./data/packages.json \
4141
-static-dir="$(UI)/build" \
4242
-gtag-id="$(GTAG)" \

cmd/playground/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@ func start(goRoot string, logger *zap.Logger, cfg *config.Config) error {
7979
buildSvc := builder.NewBuildService(zap.L(), buildCfg, store)
8080

8181
// Start cleanup service
82-
cleanupSvc := builder.NewCleanupDispatchService(zap.L(), cfg.Build.CleanupInterval, buildSvc, store)
83-
go cleanupSvc.Start(ctx)
82+
if !cfg.Build.SkipModuleCleanup {
83+
cleanupSvc := builder.NewCleanupDispatchService(zap.L(), cfg.Build.CleanupInterval, buildSvc, store)
84+
go cleanupSvc.Start(ctx)
85+
}
8486

8587
// Initialize API endpoints
8688
r := mux.NewRouter()

internal/builder/check.go

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,77 @@ const (
1212
maxFileCount = 12
1313
)
1414

15-
func checkFileEntries(entries map[string][]byte) error {
15+
type projectType int
16+
17+
const (
18+
projectTypeProgram projectType = iota
19+
projectTypeTest
20+
)
21+
22+
type projectInfo struct {
23+
projectType projectType
24+
}
25+
26+
// checkFileEntries validates project file extensions and contents.
27+
//
28+
// In result returns project information such as whether this is a regular Go program or test.
29+
func checkFileEntries(entries map[string][]byte) (projectInfo, error) {
1630
if len(entries) == 0 {
17-
return newBuildError("no buildable Go source files")
31+
return projectInfo{}, newBuildError("no buildable Go source files")
1832
}
1933

2034
if len(entries) > maxFileCount {
21-
return newBuildError("too many files (max: %d)", maxFileCount)
35+
return projectInfo{}, newBuildError("too many files (max: %d)", maxFileCount)
36+
}
37+
38+
info := projectInfo{
39+
projectType: projectTypeProgram,
2240
}
2341

2442
for name, contents := range entries {
43+
projType, err := checkFilePath(name)
44+
if err != nil {
45+
return info, err
46+
}
47+
2548
if len(bytes.TrimSpace(contents)) == 0 {
26-
return newBuildError("file %s is empty", name)
49+
return projectInfo{}, newBuildError("file %s is empty", name)
2750
}
2851

29-
if err := checkFilePath(name); err != nil {
30-
return err
52+
if projType == projectTypeTest {
53+
info.projectType = projType
3154
}
3255
}
3356

34-
return nil
57+
return info, nil
3558
}
3659

37-
func checkFilePath(fpath string) error {
60+
// checkFilePath check if file extension and path are correct.
61+
//
62+
// Also, if file is located at root, returns its Go file type - test or regular file.
63+
func checkFilePath(fpath string) (projectType, error) {
64+
projType := projectTypeProgram
3865
if err := goplay.ValidateFilePath(fpath, true); err != nil {
39-
return newBuildError(err.Error())
66+
return projType, newBuildError(err.Error())
4067
}
4168

4269
pathDepth := strings.Count(fpath, "/")
4370
if pathDepth > maxPathDepth {
44-
return newBuildError("file path is too deep: %s", fpath)
71+
return projType, newBuildError("file path is too deep: %s", fpath)
72+
}
73+
74+
isRoot := false
75+
switch pathDepth {
76+
case 0:
77+
isRoot = true
78+
case 1:
79+
// Path might be root but start with slash
80+
isRoot = strings.HasPrefix(fpath, "/")
81+
}
82+
83+
if isRoot && strings.HasSuffix(fpath, "_test.go") {
84+
projType = projectTypeTest
4585
}
4686

47-
return nil
87+
return projType, nil
4888
}

internal/builder/check_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package builder
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestCheckFileEntries(t *testing.T) {
12+
cases := map[string]struct {
13+
input map[string][]byte
14+
inputFn func() map[string][]byte
15+
expect projectInfo
16+
err string
17+
}{
18+
"empty entries": {
19+
input: map[string][]byte{},
20+
expect: projectInfo{},
21+
err: "no buildable Go source files",
22+
},
23+
"too many files": {
24+
inputFn: func() map[string][]byte {
25+
out := make(map[string][]byte)
26+
for i := 0; i < maxFileCount+1; i++ {
27+
key := fmt.Sprintf("file%d.go", i)
28+
out[key] = []byte("package main")
29+
}
30+
return out
31+
},
32+
err: fmt.Sprintf("too many files (max: %d)", maxFileCount),
33+
},
34+
"invalid file name": {
35+
input: map[string][]byte{
36+
"": nil,
37+
},
38+
err: "file name cannot be empty",
39+
},
40+
"empty file content": {
41+
input: map[string][]byte{
42+
"main.go": nil,
43+
},
44+
err: "file main.go is empty",
45+
},
46+
"valid single program file": {
47+
input: map[string][]byte{
48+
"main.go": []byte("package main"),
49+
},
50+
expect: projectInfo{
51+
projectType: projectTypeProgram,
52+
},
53+
},
54+
"valid single test file": {
55+
input: map[string][]byte{
56+
"main_test.go": []byte("package main"),
57+
},
58+
expect: projectInfo{
59+
projectType: projectTypeTest,
60+
},
61+
},
62+
"valid program project with subpackage": {
63+
input: map[string][]byte{
64+
"pkg/foo_test.go": []byte("package main"),
65+
"package.go": []byte("package main"),
66+
},
67+
expect: projectInfo{
68+
projectType: projectTypeProgram,
69+
},
70+
},
71+
"valid test project with subpackage": {
72+
input: map[string][]byte{
73+
"pkg/main.go": []byte("package main"),
74+
"util_test.go": []byte("package main"),
75+
},
76+
expect: projectInfo{
77+
projectType: projectTypeTest,
78+
},
79+
},
80+
"valid multiple files with test file": {
81+
input: map[string][]byte{
82+
"main.go": []byte("package main"),
83+
"util_test.go": []byte("package main"),
84+
},
85+
expect: projectInfo{
86+
projectType: projectTypeTest,
87+
},
88+
},
89+
"file path too deep": {
90+
err: fmt.Sprintf("file path is too deep: %smain.go", strings.Repeat("dir/", maxPathDepth+1)),
91+
inputFn: func() map[string][]byte {
92+
key := strings.Repeat("dir/", maxPathDepth+1)
93+
key += "main.go"
94+
return map[string][]byte{
95+
key: []byte("package main"),
96+
}
97+
},
98+
},
99+
}
100+
101+
for name, tc := range cases {
102+
t.Run(name, func(t *testing.T) {
103+
input := tc.input
104+
if tc.inputFn != nil {
105+
input = tc.inputFn()
106+
}
107+
108+
result, err := checkFileEntries(input)
109+
if tc.err != "" {
110+
assert.Error(t, err)
111+
assert.EqualError(t, err, tc.err)
112+
} else {
113+
assert.NoError(t, err)
114+
}
115+
assert.Equal(t, tc.expect, result)
116+
})
117+
}
118+
}

internal/builder/compiler.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ var predefinedBuildVars = osutil.EnvironmentVariables{
2626
type Result struct {
2727
// FileName is artifact file name
2828
FileName string
29+
30+
// IsTest indicates whether binary is a test file
31+
IsTest bool
2932
}
3033

3134
// BuildEnvironmentConfig is BuildService environment configuration.
@@ -70,7 +73,8 @@ func (s BuildService) GetArtifact(id storage.ArtifactID) (storage.ReadCloseSizer
7073

7174
// Build compiles Go source to WASM and returns result
7275
func (s BuildService) Build(ctx context.Context, files map[string][]byte) (*Result, error) {
73-
if err := checkFileEntries(files); err != nil {
76+
projInfo, err := checkFileEntries(files)
77+
if err != nil {
7478
return nil, err
7579
}
7680

@@ -79,7 +83,11 @@ func (s BuildService) Build(ctx context.Context, files map[string][]byte) (*Resu
7983
return nil, err
8084
}
8185

82-
result := &Result{FileName: aid.Ext(storage.ExtWasm)}
86+
result := &Result{
87+
FileName: aid.Ext(storage.ExtWasm),
88+
IsTest: projInfo.projectType == projectTypeTest,
89+
}
90+
8391
isCached, err := s.storage.HasItem(aid)
8492
if err != nil {
8593
s.log.Error("failed to check cache", zap.Stringer("artifact", aid), zap.Error(err))
@@ -106,16 +114,20 @@ func (s BuildService) Build(ctx context.Context, files map[string][]byte) (*Resu
106114
return nil, err
107115
}
108116

109-
err = s.buildSource(ctx, workspace)
117+
err = s.buildSource(ctx, projInfo, workspace)
110118
return result, err
111119
}
112120

113-
func (s BuildService) buildSource(ctx context.Context, workspace *storage.Workspace) error {
121+
func (s BuildService) buildSource(ctx context.Context, projInfo projectInfo, workspace *storage.Workspace) error {
114122
// Populate go.mod and go.sum files.
115123
if err := s.runGoTool(ctx, workspace.WorkDir, "mod", "tidy"); err != nil {
116124
return err
117125
}
118126

127+
if projInfo.projectType == projectTypeTest {
128+
return s.runGoTool(ctx, workspace.WorkDir, "test", "-c", "-o", workspace.BinaryPath)
129+
}
130+
119131
return s.runGoTool(ctx, workspace.WorkDir, "build", "-o", workspace.BinaryPath, ".")
120132
}
121133

internal/builder/compiler_test.go

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"io/ioutil"
77
"os"
8-
"os/exec"
98
"strings"
109
"syscall"
1110
"testing"
@@ -261,15 +260,46 @@ func TestBuildService_Build(t *testing.T) {
261260
},
262261
cmdRunner: func(t *testing.T, ctrl *gomock.Controller) CommandRunner {
263262
m := NewMockCommandRunner(ctrl)
264-
m.EXPECT().RunCommand(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error {
265-
require.Equal(t, cmd.Args, []string{
266-
"go", "clean", "-modcache", "-cache", "-testcache", "-fuzzcache",
267-
})
268-
return nil
269-
}).Times(1)
263+
m.EXPECT().RunCommand(testutil.MatchCommand("go", "clean", "-modcache", "-cache", "-testcache", "-fuzzcache")).Return(nil)
270264
return m
271265
},
272266
},
267+
"unit test build": {
268+
files: map[string][]byte{
269+
"main_test.go": []byte("package main"),
270+
"go.mod": []byte("module foo"),
271+
},
272+
store: func(t *testing.T, _ map[string][]byte) (storage.StoreProvider, func() error) {
273+
return testStorage{
274+
hasItem: func(id storage.ArtifactID) (bool, error) {
275+
return false, nil
276+
},
277+
createWorkspace: func(id storage.ArtifactID, entries map[string][]byte) (*storage.Workspace, error) {
278+
return &storage.Workspace{
279+
WorkDir: "/tmp",
280+
BinaryPath: "test.wasm",
281+
Files: nil,
282+
}, nil
283+
},
284+
clean: func(_ context.Context) error {
285+
t.Log("cleanup called")
286+
return nil
287+
},
288+
}, nil
289+
},
290+
cmdRunner: func(t *testing.T, ctrl *gomock.Controller) CommandRunner {
291+
m := NewMockCommandRunner(ctrl)
292+
m.EXPECT().RunCommand(testutil.MatchCommand("go", "mod", "tidy")).Return(nil)
293+
m.EXPECT().RunCommand(testutil.MatchCommand("go", "test", "-c", "-o", "test.wasm")).Return(nil)
294+
return m
295+
},
296+
wantResult: func(files map[string][]byte) *Result {
297+
return &Result{
298+
FileName: mustArtifactID(t, files).String() + ".wasm",
299+
IsTest: true,
300+
}
301+
},
302+
},
273303
}
274304

275305
for n, c := range cases {

internal/builder/storage/artifact.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"crypto/md5"
66
"encoding/hex"
7+
"sort"
78
)
89

910
const (
@@ -30,17 +31,25 @@ func (a ArtifactID) String() string {
3031
func GetArtifactID(entries map[string][]byte) (ArtifactID, error) {
3132
h := md5.New()
3233

33-
isFirst := true
34-
for name, contents := range entries {
35-
if !isFirst {
34+
// Keys have to be sorted for constant hashing
35+
keys := make([]string, 0, len(entries))
36+
for key := range entries {
37+
keys = append(keys, key)
38+
}
39+
sort.Strings(keys)
40+
41+
for i, key := range keys {
42+
if i > 0 {
3643
_, _ = h.Write([]byte("\n"))
3744
}
38-
isFirst = false
45+
46+
contents := bytes.TrimSpace(entries[key])
3947
_, _ = h.Write([]byte("-- "))
40-
_, _ = h.Write([]byte(name))
48+
_, _ = h.Write([]byte(key))
4149
_, _ = h.Write([]byte(" --\n"))
42-
_, _ = h.Write(bytes.TrimSpace(contents))
50+
_, _ = h.Write(contents)
4351
}
52+
4453
fName := hex.EncodeToString(h.Sum(nil))
4554
return ArtifactID(fName), nil
4655
}

internal/server/handler_v1.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ func (s *APIv1Handler) HandleCompile(w http.ResponseWriter, r *http.Request) err
354354
return err
355355
}
356356

357-
resp := BuildResponse{FileName: result.FileName}
357+
resp := BuildResponseV1{FileName: result.FileName}
358358
if changed {
359359
// Return formatted code if goimports had any effect
360360
resp.Formatted = string(src)

0 commit comments

Comments
 (0)