├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd └── cleanenv │ ├── main.go │ └── main_test.go ├── filesys ├── fd.go ├── fd_windows.go ├── handler.go ├── handler_test.go ├── stat.go ├── stat_windows.go ├── statmap_darwin.go └── statmap_linux.go ├── func_map.go ├── func_map_test.go ├── go.mod ├── go.sum ├── handler.go ├── http_server.go ├── index.html ├── main.go ├── main_test.go ├── parse.go ├── parse_test.go ├── profiler.go ├── profiler_test.go └── testdata ├── pprof.out ├── test.wasm └── wasm.prof /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: CI 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.23.x, 1.24.x] 8 | os: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Install chrome 16 | uses: browser-actions/setup-chrome@latest 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | - name: Lint 20 | run: | 21 | go vet . 22 | gofmt -l -s -w . 23 | - name: Install cleanenv 24 | run: go install ./cmd/cleanenv 25 | - name: Disable AppArmor 26 | if: runner.os == 'Linux' 27 | run: | 28 | # Disable AppArmor for Ubuntu 23.10+. 29 | # https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md 30 | echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns 31 | - name: Test 32 | run: cleanenv -remove-prefix GITHUB_ -remove-prefix JAVA_ -remove-prefix PSModulePath -remove-prefix STATS_ -remove-prefix RUNNER_ -- go test -v -race -timeout 5m ./... 33 | - name: Install 34 | run: go install 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore browser drivers 2 | /go_js_wasm_exec 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Agniva De Sarker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wasmbrowsertest [![Build Status](https://github.com/agnivade/wasmbrowsertest/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/agnivade/wasmbrowsertest/actions/workflows/ci.yml) 2 | 3 | Run Go wasm tests easily in your browser. 4 | 5 | If you have a codebase targeting the wasm platform, chances are you would want to test your code in a browser. Currently, that process is a bit cumbersome: 6 | - The test needs to be compiled to a wasm file. 7 | - Then loaded into an HTML file along with the wasm_exec.js. 8 | - And finally, this needs to be served with a static file server and then loaded in the browser. 9 | 10 | This tool automates all of that. So you just have to type `GOOS=js GOARCH=wasm go test`, and it automatically executes the tests inside a browser ! 11 | 12 | ## Quickstart 13 | 14 | - `go install github.com/agnivade/wasmbrowsertest@latest`. This will place the binary in $GOPATH/bin, or $GOBIN, if that has a different value. 15 | - Rename the binary to `go_js_wasm_exec`. 16 | - Add $GOBIN to $PATH if it is not already done. 17 | - Run tests as usual: `GOOS=js GOARCH=wasm go test`. 18 | - You can also take a cpu profile. Set the `-cpuprofile` flag for that. 19 | 20 | ## Ok, but how does the magic work ? 21 | 22 | `go test` allows invocation of a different binary to run a test. `go help test` has a line: 23 | 24 | ``` 25 | -exec xprog 26 | Run the test binary using xprog. The behavior is the same as 27 | in 'go run'. See 'go help run' for details. 28 | ``` 29 | 30 | And `go help run` says: 31 | 32 | ``` 33 | By default, 'go run' runs the compiled binary directly: 'a.out arguments...'. 34 | If the -exec flag is given, 'go run' invokes the binary using xprog: 35 | 'xprog a.out arguments...'. 36 | If the -exec flag is not given, GOOS or GOARCH is different from the system 37 | default, and a program named go_$GOOS_$GOARCH_exec can be found 38 | on the current search path, 'go run' invokes the binary using that program, 39 | for example 'go_nacl_386_exec a.out arguments...'. This allows execution of 40 | cross-compiled programs when a simulator or other execution method is 41 | available. 42 | ``` 43 | 44 | So essentially, there are 2 ways: 45 | - Either have a binary with the name of `go_js_wasm_exec` in your $PATH. 46 | - Or set the `-exec` flag in your tests. 47 | 48 | Use whatever works for you. 49 | 50 | ### How is a CPU profile taken ? 51 | 52 | A CPU profile is run during the duration of the test, and then converted to the pprof format so that it can be natively analyzed with the Go toolchain. 53 | 54 | ### Can I run something which is not a test ? 55 | 56 | Yep. `GOOS=js GOARCH=wasm go run main.go` also works. If you want to actually see the application running in the browser, set the `WASM_HEADLESS` variable to `off` like so `WASM_HEADLESS=off GOOS=js GOARCH=wasm go run main.go`. 57 | 58 | ### Can I use this inside Travis ? 59 | 60 | Sure. 61 | 62 | Add these lines to your `.travis.yml` 63 | 64 | ``` 65 | addons: 66 | chrome: stable 67 | 68 | install: 69 | - go install github.com/agnivade/wasmbrowsertest@latest 70 | - mv $GOPATH/bin/wasmbrowsertest $GOPATH/bin/go_js_wasm_exec 71 | - export PATH=$GOPATH/bin:$PATH 72 | ``` 73 | 74 | Now, just setting `GOOS=js GOARCH=wasm` will run your tests using `wasmbrowsertest`. For other CI environments, you have to do something similar. 75 | 76 | ### Can I use this inside Github Action? 77 | 78 | Sure. 79 | 80 | Add these lines to your `.github/workflows/ci.yml` 81 | 82 | PS: adjust the go version you need in go-version section 83 | 84 | ``` 85 | on: [push, pull_request] 86 | name: Unit Test 87 | jobs: 88 | test: 89 | strategy: 90 | matrix: 91 | go-version: [1.xx.x] 92 | os: [ubuntu-latest] 93 | runs-on: ${{ matrix.os }} 94 | steps: 95 | - name: Install Go 96 | uses: actions/setup-go@v2 97 | with: 98 | go-version: ${{ matrix.go-version }} 99 | - name: Install chrome 100 | uses: browser-actions/setup-chrome@latest 101 | - name: Install dep 102 | run: go install github.com/agnivade/wasmbrowsertest@latest 103 | - name: Setup wasmexec 104 | run: mv $(go env GOPATH)/bin/wasmbrowsertest $(go env GOPATH)/bin/go_js_wasm_exec 105 | - name: Checkout code 106 | uses: actions/checkout@v2 107 | ``` 108 | 109 | ### What sorts of browsers are supported ? 110 | 111 | This tool uses the [ChromeDP](https://chromedevtools.github.io/devtools-protocol/) protocol to run the tests inside a Chrome browser. So Chrome or any blink-based browser will work. 112 | 113 | ### Why not firefox ? 114 | 115 | Great question. The initial idea was to use a Selenium API and drive any browser to run the tests. But unfortunately, geckodriver does not support the ability to capture console logs - https://github.com/mozilla/geckodriver/issues/284. Hence, the shift to use the ChromeDP protocol circumvents the need to have any external driver binary and just have a browser installed in the machine. 116 | 117 | ### A tip on coverage data using go 1.20 or later: 118 | 119 | Code coverage changes introduced in go 1.20 produce multiple 120 | [coverage data files](https://go.dev/testing/coverage/#working) in binary format. 121 | 122 | In wasmbrowsertest, file system operations for coverage files occur via HTTP API calls. 123 | 124 | Prefer using `-test.gocoverdir=/path/to/coverage` instead of `-test.coverprofile=coverage.out` 125 | when coverage data is needed. This will prevent http api calls that would read all the coverage data 126 | files and write the larger `coverage.out` file. 127 | 128 | In a subsequent step, use `go tool covdata -i /path/to/coverage -o coverage.out` or similar to process coverage 129 | data files into the desired output format. An additional benefit is that multiple test coverage runs that write 130 | their data to the same coverage directory can be merged together with this command. 131 | 132 | ## Errors 133 | 134 | ### `total length of command line and environment variables exceeds limit` 135 | 136 | If the error `total length of command line and environment variables exceeds limit` appears, then 137 | the current environment variables' total size has exceeded the maximum when executing Go Wasm binaries. 138 | 139 | To resolve this issue, install `cleanenv` and use it to prefix your command. 140 | 141 | For example, if these commands are used: 142 | ```bash 143 | export GOOS=js GOARCH=wasm 144 | go test -cover ./... 145 | ``` 146 | The new commands should be the following: 147 | ```bash 148 | go install github.com/agnivade/wasmbrowsertest/cmd/cleanenv@latest 149 | 150 | export GOOS=js GOARCH=wasm 151 | cleanenv -remove-prefix GITHUB_ -- go test -cover ./... 152 | ``` 153 | 154 | The `cleanenv` command above removes all environment variables prefixed with `GITHUB_` before running the command after the `--`. 155 | The `-remove-prefix` flag can be repeated multiple times to remove even more environment variables. 156 | -------------------------------------------------------------------------------- /cmd/cleanenv/main.go: -------------------------------------------------------------------------------- 1 | // Command cleanenv removes all environment variables that match given prefixes before running its arguments as a command. 2 | // 3 | // For example, this is useful in GitHub Actions: 4 | // 5 | // export GOOS=js GOARCH=wasm 6 | // cleanenv -remove-prefix GITHUB_ -- go test -cover ./... 7 | // 8 | // The '-remove-prefix' flag can be repeated multiple times to remove even more environment variables. 9 | package main 10 | 11 | import ( 12 | "errors" 13 | "flag" 14 | "fmt" 15 | "io" 16 | "os" 17 | "os/exec" 18 | "strings" 19 | ) 20 | 21 | func main() { 22 | app := App{ 23 | Args: os.Args[1:], 24 | Env: os.Environ(), 25 | StdOut: os.Stdout, 26 | ErrOut: os.Stderr, 27 | } 28 | err := app.Run() 29 | if err != nil { 30 | fmt.Fprintln(app.ErrOut, err) 31 | exitCode := 1 32 | var exitErr *exec.ExitError 33 | if errors.As(err, &exitErr) { 34 | exitCode = exitErr.ExitCode() 35 | } 36 | os.Exit(exitCode) 37 | } 38 | } 39 | 40 | type App struct { 41 | Args []string 42 | Env []string 43 | StdOut, ErrOut io.Writer 44 | } 45 | 46 | func (a App) Run() error { 47 | set := flag.NewFlagSet("cleanenv", flag.ContinueOnError) 48 | var removePrefixes StringSliceFlag 49 | set.Var(&removePrefixes, "remove-prefix", "Remove one or more environment variables with the given prefixes.") 50 | if err := set.Parse(a.Args); err != nil { 51 | return err 52 | } 53 | 54 | var cleanEnv []string 55 | for _, keyValue := range a.Env { 56 | tokens := strings.SplitN(keyValue, "=", 2) 57 | if allowEnvName(tokens[0], removePrefixes) { 58 | cleanEnv = append(cleanEnv, keyValue) 59 | } 60 | } 61 | 62 | arg0, argv, err := splitArgs(set.Args()) 63 | if err != nil { 64 | return err 65 | } 66 | cmd := exec.Command(arg0, argv...) 67 | cmd.Env = cleanEnv 68 | cmd.Stdout = a.StdOut 69 | cmd.Stderr = a.ErrOut 70 | return cmd.Run() 71 | } 72 | 73 | type StringSliceFlag []string 74 | 75 | func (s *StringSliceFlag) Set(value string) error { 76 | *s = append(*s, value) 77 | return nil 78 | } 79 | 80 | func (s *StringSliceFlag) String() string { 81 | return strings.Join(*s, ", ") 82 | } 83 | 84 | func allowEnvName(name string, removePrefixes []string) bool { 85 | for _, prefix := range removePrefixes { 86 | if strings.HasPrefix(name, prefix) { 87 | return false 88 | } 89 | } 90 | return true 91 | } 92 | 93 | func splitArgs(args []string) (string, []string, error) { 94 | if len(args) == 0 { 95 | return "", nil, errors.New("not enough args to run a command") 96 | } 97 | return args[0], args[1:], nil 98 | } 99 | -------------------------------------------------------------------------------- /cmd/cleanenv/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestRun(t *testing.T) { 10 | t.Parallel() 11 | const bashPrintCleanVars = `env | grep CLEAN_ | sort | tr '\n' ' '` 12 | for _, tc := range []struct { 13 | name string 14 | env []string 15 | args []string 16 | expectOutput string 17 | expectErr string 18 | }{ 19 | { 20 | name: "zero args", 21 | expectErr: "not enough args to run a command", 22 | }, 23 | { 24 | name: "all env passed through", 25 | env: []string{ 26 | "CLEAN_BAR=bar", 27 | "CLEAN_FOO=foo", 28 | }, 29 | args: []string{"bash", "-c", bashPrintCleanVars}, 30 | expectOutput: "CLEAN_BAR=bar CLEAN_FOO=foo", 31 | }, 32 | { 33 | name: "remove one variable prefix", 34 | env: []string{ 35 | "CLEAN_BAR=bar", 36 | "CLEAN_FOO=foo", 37 | }, 38 | args: []string{ 39 | "-remove-prefix=CLEAN_BAR", "--", 40 | "bash", "-c", bashPrintCleanVars, 41 | }, 42 | expectOutput: "CLEAN_FOO=foo", 43 | }, 44 | { 45 | name: "remove common variable prefix", 46 | env: []string{ 47 | "CLEAN_COMMON_BAR=bar", 48 | "CLEAN_COMMON_BAZ=baz", 49 | "CLEAN_FOO=foo", 50 | }, 51 | args: []string{ 52 | "-remove-prefix=CLEAN_COMMON_", "--", 53 | "bash", "-c", bashPrintCleanVars, 54 | }, 55 | expectOutput: "CLEAN_FOO=foo", 56 | }, 57 | { 58 | name: "remove multiple prefixes", 59 | env: []string{ 60 | "CLEAN_BAR=bar", 61 | "CLEAN_BAZ=baz", 62 | "CLEAN_FOO=foo", 63 | }, 64 | args: []string{ 65 | "-remove-prefix=CLEAN_BAR", 66 | "-remove-prefix=CLEAN_FOO", "--", 67 | "bash", "-c", bashPrintCleanVars, 68 | }, 69 | expectOutput: "CLEAN_BAZ=baz", 70 | }, 71 | } { 72 | tc := tc // enable parallel sub-tests 73 | t.Run(tc.name, func(t *testing.T) { 74 | t.Parallel() 75 | var output bytes.Buffer 76 | app := App{ 77 | Args: tc.args, 78 | Env: tc.env, 79 | StdOut: &output, 80 | ErrOut: &output, 81 | } 82 | err := app.Run() 83 | assertEqualError(t, tc.expectErr, err) 84 | if tc.expectErr != "" { 85 | return 86 | } 87 | 88 | outputStr := strings.TrimSpace(output.String()) 89 | if outputStr != tc.expectOutput { 90 | t.Errorf("Unexpected output: %q != %q", tc.expectOutput, outputStr) 91 | } 92 | }) 93 | } 94 | } 95 | 96 | func assertEqualError(t *testing.T, expected string, err error) { 97 | t.Helper() 98 | if expected == "" { 99 | if err != nil { 100 | t.Error("Unexpected error:", err) 101 | } 102 | return 103 | } 104 | 105 | if err == nil { 106 | t.Error("Expected error, got nil") 107 | return 108 | } 109 | message := err.Error() 110 | if expected != message { 111 | t.Errorf("Unexpected error message: %q != %q", expected, message) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /filesys/fd.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || linux 2 | 3 | package filesys 4 | 5 | func FdType(fd int) int { 6 | return fd 7 | } 8 | -------------------------------------------------------------------------------- /filesys/fd_windows.go: -------------------------------------------------------------------------------- 1 | package filesys 2 | 3 | import "syscall" 4 | 5 | func FdType(fd int) syscall.Handle { 6 | return syscall.Handle(fd) 7 | } 8 | -------------------------------------------------------------------------------- /filesys/handler.go: -------------------------------------------------------------------------------- 1 | package filesys 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "syscall" 13 | ) 14 | 15 | // Handler translates json payload data to and from system calls like syscall.Stat 16 | type Handler struct { 17 | debug bool 18 | securityToken string 19 | logger *log.Logger 20 | } 21 | 22 | func NewHandler(securityToken string, logger *log.Logger) *Handler { 23 | return &Handler{ 24 | debug: false, 25 | securityToken: securityToken, 26 | logger: logger, 27 | } 28 | } 29 | 30 | func (fa *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 31 | if r.Header.Get("WBT-Token") != fa.securityToken { 32 | fa.doError("not implemented", "ENOSYS", w, errors.New("missing WBT-token")) 33 | return 34 | } 35 | switch r.URL.Path { 36 | case "/fs/stat": 37 | fa.handle(&Stat{}, w, r) 38 | case "/fs/fstat": 39 | fa.handle(&Fstat{}, w, r) 40 | case "/fs/open": 41 | fa.handle(&Open{}, w, r) 42 | case "/fs/write": 43 | fa.handle(&Write{}, w, r) 44 | case "/fs/close": 45 | fa.handle(&Close{}, w, r) 46 | case "/fs/rename": 47 | fa.handle(&Rename{}, w, r) 48 | case "/fs/readdir": 49 | fa.handle(&Readdir{}, w, r) 50 | case "/fs/lstat": 51 | fa.handle(&Lstat{}, w, r) 52 | case "/fs/read": 53 | fa.handle(&Read{}, w, r) 54 | case "/fs/mkdir": 55 | fa.handle(&Mkdir{}, w, r) 56 | case "/fs/unlink": 57 | fa.handle(&Unlink{}, w, r) 58 | case "/fs/rmdir": 59 | fa.handle(&Rmdir{}, w, r) 60 | default: 61 | fa.doError("not implemented", "ENOSYS", w, 62 | fmt.Errorf("unsupported api path %q", r.URL.Path)) 63 | } 64 | } 65 | 66 | type Responder interface { 67 | WriteResponse(fa *Handler, w http.ResponseWriter) 68 | } 69 | 70 | func (fa *Handler) handle(responder Responder, w http.ResponseWriter, r *http.Request) { 71 | if err := json.NewDecoder(r.Body).Decode(responder); err != nil { 72 | fa.logger.Printf("ERROR handle : %v\n", err) 73 | w.WriteHeader(http.StatusBadRequest) 74 | return 75 | } 76 | if fa.debug { 77 | fa.logger.Printf("handle %s %+v\n", r.URL.Path, responder) 78 | } 79 | responder.WriteResponse(fa, w) 80 | } 81 | 82 | type ErrorCode struct { 83 | Error string `json:"error"` 84 | Code string `json:"code"` 85 | } 86 | 87 | func (fa *Handler) doError(msg, code string, w http.ResponseWriter, err error) { 88 | if fa.debug { 89 | fa.logger.Printf("doError %s : %s\n", msg, code) 90 | } 91 | if err != nil { 92 | fa.logger.Printf("Error: %v", err) 93 | } 94 | 95 | e := &ErrorCode{Error: msg, Code: code} 96 | 97 | w.WriteHeader(http.StatusBadRequest) 98 | w.Header().Set("Content-Type", "application/json") 99 | if err := json.NewEncoder(w).Encode(e); err != nil { 100 | fa.logger.Printf("Error encoding json: %v", err) 101 | } 102 | } 103 | 104 | func (fa *Handler) okResponse(data any, w http.ResponseWriter) { 105 | var marshal []byte 106 | var err error 107 | if marshal, err = json.Marshal(data); err != nil { 108 | fa.logger.Println("okResponse json error:", err) 109 | w.WriteHeader(http.StatusInternalServerError) 110 | return 111 | } 112 | if fa.debug { 113 | fa.logger.Printf("okResponse %s\n", string(marshal)) 114 | } 115 | 116 | w.WriteHeader(http.StatusOK) 117 | w.Header().Set("Content-Type", "application/json") 118 | if _, err = w.Write(marshal); err != nil { 119 | fa.logger.Printf("Error writing json: %v", err) 120 | } 121 | } 122 | 123 | func fixPath(path string) string { 124 | return strings.TrimPrefix(path, "/fs/") 125 | } 126 | 127 | type Stat struct { 128 | Path string `json:"path,omitempty"` 129 | } 130 | 131 | type Open struct { 132 | Path string `json:"path"` 133 | Flags int `json:"flags"` 134 | Mode uint32 `json:"mode"` 135 | } 136 | 137 | func (o *Open) WriteResponse(fa *Handler, w http.ResponseWriter) { 138 | fd, err := syscall.Open(fixPath(o.Path), o.Flags, o.Mode) 139 | if fa.handleError(w, err, true) { 140 | return 141 | } 142 | response := map[string]any{"fd": fd} 143 | fa.okResponse(response, w) 144 | } 145 | 146 | type Fstat struct { 147 | Fd int `json:"fd"` 148 | } 149 | 150 | type Write struct { 151 | Fd int `json:"fd"` 152 | Buffer string `json:"buffer"` 153 | Offset int `json:"offset"` 154 | Length int `json:"length"` 155 | Position *int `json:"position,omitempty"` 156 | } 157 | 158 | func (wr *Write) WriteResponse(fa *Handler, w http.ResponseWriter) { 159 | if wr.Offset != 0 { 160 | fa.doError("not implemented", "ENOSYS", w, 161 | fmt.Errorf("write offset %d not supported", wr.Offset)) 162 | return 163 | } 164 | if wr.Position != nil { 165 | _, err := syscall.Seek(FdType(wr.Fd), int64(*wr.Position), 0) 166 | if err != nil { 167 | fa.doError("not implemented", "ENOSYS", w, err) 168 | return 169 | } 170 | } 171 | 172 | bytes, err := base64.StdEncoding.DecodeString(wr.Buffer) 173 | if err != nil { 174 | fa.doError("not implemented", "ENOSYS", w, err) 175 | return 176 | } 177 | 178 | var written int 179 | written, err = syscall.Write(FdType(wr.Fd), bytes) 180 | if err != nil { 181 | fa.doError("not implemented", "ENOSYS", w, err) 182 | return 183 | } 184 | 185 | fa.okResponse(map[string]any{"written": written}, w) 186 | } 187 | 188 | type Close struct { 189 | Fd int `json:"fd"` 190 | } 191 | 192 | func (c *Close) WriteResponse(fa *Handler, w http.ResponseWriter) { 193 | err := syscall.Close(FdType(c.Fd)) 194 | if fa.handleError(w, err, false) { 195 | return 196 | } 197 | fa.okResponse(map[string]any{}, w) 198 | } 199 | 200 | type Rename struct { 201 | From string `json:"from"` 202 | To string `json:"to"` 203 | } 204 | 205 | func (r *Rename) WriteResponse(fa *Handler, w http.ResponseWriter) { 206 | err := syscall.Rename(fixPath(r.From), fixPath(r.To)) 207 | if fa.handleError(w, err, true) { 208 | return 209 | } 210 | fa.okResponse(map[string]any{}, w) 211 | } 212 | 213 | type Readdir struct { 214 | Path string `json:"path"` 215 | } 216 | 217 | func (r *Readdir) WriteResponse(fa *Handler, w http.ResponseWriter) { 218 | entries, err := os.ReadDir(fixPath(r.Path)) 219 | if fa.handleError(w, err, false) { 220 | return 221 | } 222 | stringNames := make([]string, len(entries)) 223 | for i, entry := range entries { 224 | stringNames[i] = entry.Name() 225 | } 226 | fa.okResponse(map[string]any{"entries": stringNames}, w) 227 | } 228 | 229 | type Lstat struct { 230 | Path string `json:"path"` 231 | } 232 | 233 | type Read struct { 234 | Fd int `json:"fd"` 235 | Offset int `json:"offset"` 236 | Length int `json:"length"` 237 | Position *int `json:"position,omitempty"` 238 | } 239 | 240 | func (r *Read) WriteResponse(fa *Handler, w http.ResponseWriter) { 241 | if r.Offset != 0 { 242 | fa.doError("not implemented", "ENOSYS", w, 243 | fmt.Errorf("read offset %d not supported", r.Offset)) 244 | return 245 | } 246 | if r.Position != nil { 247 | _, err := syscall.Seek(FdType(r.Fd), int64(*r.Position), 0) 248 | if err != nil { 249 | fa.doError("not implemented", "ENOSYS", w, err) 250 | return 251 | } 252 | } 253 | 254 | buffer := make([]byte, r.Length) 255 | read, err := syscall.Read(FdType(r.Fd), buffer) 256 | if err != nil { 257 | fa.doError("not implemented", "ENOSYS", w, err) 258 | return 259 | } 260 | response := map[string]any{ 261 | "read": read, 262 | "buffer": base64.StdEncoding.EncodeToString(buffer[:read]), 263 | } 264 | fa.okResponse(response, w) 265 | 266 | } 267 | 268 | type Mkdir struct { 269 | Path string `json:"path"` 270 | Perm uint32 `json:"perm"` 271 | } 272 | 273 | func (m *Mkdir) WriteResponse(fa *Handler, w http.ResponseWriter) { 274 | err := syscall.Mkdir(fixPath(m.Path), m.Perm) 275 | if err != nil { 276 | fa.doError("not implemented", "ENOSYS", w, err) 277 | return 278 | } 279 | fa.okResponse(map[string]any{}, w) 280 | } 281 | 282 | type Unlink struct { 283 | Path string `json:"path"` 284 | } 285 | 286 | func (u *Unlink) WriteResponse(fa *Handler, w http.ResponseWriter) { 287 | err := syscall.Unlink(fixPath(u.Path)) 288 | if err != nil { 289 | fa.doError("not implemented", "ENOSYS", w, err) 290 | return 291 | } 292 | fa.okResponse(map[string]any{}, w) 293 | } 294 | 295 | type Rmdir struct { 296 | Path string `json:"path"` 297 | } 298 | 299 | func (r *Rmdir) WriteResponse(fa *Handler, w http.ResponseWriter) { 300 | err := syscall.Rmdir(fixPath(r.Path)) 301 | if fa.handleError(w, err, true) { 302 | return 303 | } 304 | fa.okResponse(map[string]any{}, w) 305 | } 306 | 307 | func (fa *Handler) handleError(w http.ResponseWriter, err error, noEnt bool) bool { 308 | if err == nil { 309 | return false 310 | } 311 | if noEnt && os.IsNotExist(err) { 312 | // We're not passing the error down for logging here since this is a 313 | // file not found condition, not an actual error condition. 314 | fa.doError(syscall.ENOENT.Error(), "ENOENT", w, nil) 315 | } else { 316 | fa.doError(syscall.ENOSYS.Error(), "ENOSYS", w, err) 317 | } 318 | return true 319 | } 320 | -------------------------------------------------------------------------------- /filesys/handler_test.go: -------------------------------------------------------------------------------- 1 | package filesys 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "math" 11 | "net/http" 12 | "net/http/httptest" 13 | "net/url" 14 | "os" 15 | "path/filepath" 16 | "runtime" 17 | "sync" 18 | "syscall" 19 | "testing" 20 | ) 21 | 22 | const TOKEN = "test_token" 23 | 24 | func TestOpen_Missing(t *testing.T) { 25 | help := Helper(t) 26 | o := &Open{Path: help.tempPath("not_found.txt"), Flags: os.O_RDONLY, Mode: 0} 27 | response := &ErrorCode{} 28 | 29 | help.httpBad(help.req("open", o, response)) 30 | help.errorCode(response.Code, "ENOENT") 31 | } 32 | 33 | func TestOpenClose(t *testing.T) { 34 | help := Helper(t) 35 | path := help.createFile("found.txt", "some data") 36 | o := &Open{Path: path, Flags: os.O_RDONLY, Mode: 0} 37 | c := &Close{} 38 | 39 | help.httpOk(help.req("open", o, c)) 40 | help.true(c.Fd != 0, "no file descriptor returned") 41 | 42 | m := help.newMap() 43 | help.httpOk(help.req("close", c, &m)) 44 | } 45 | 46 | func TestStat(t *testing.T) { 47 | help := Helper(t) 48 | foundFile := help.createFile("found.txt", "some data") 49 | payload := &Stat{Path: foundFile} 50 | m := help.newMap() 51 | 52 | help.httpOk(help.req("stat", payload, &m)) 53 | help.checkStatMap(m, foundFile) 54 | } 55 | 56 | func TestStat_Missing(t *testing.T) { 57 | help := Helper(t) 58 | notFoundFile := help.tempPath("not_found.txt") 59 | payload := &Stat{Path: notFoundFile} 60 | errorCode := &ErrorCode{} 61 | 62 | help.httpBad(help.req("stat", payload, &errorCode)) 63 | help.errorCode(errorCode.Code, "ENOENT") 64 | } 65 | 66 | func TestFstat(t *testing.T) { 67 | help := Helper(t) 68 | tempFile := help.createFile("exists", "some data") 69 | fd, closeFd := help.sysOpen(tempFile) 70 | defer closeFd() 71 | 72 | m := help.newMap() 73 | fstat := map[string]any{"fd": fd} 74 | 75 | help.httpOk(help.req("fstat", fstat, &m)) 76 | help.checkStatMap(m, tempFile) 77 | 78 | // bad file descriptor test 79 | fstat = map[string]any{"fd": math.MaxInt64} 80 | help.httpBad(help.req("fstat", fstat, &m)) 81 | } 82 | 83 | func TestLstat(t *testing.T) { 84 | help := Helper(t) 85 | exists := help.createFile("exists.txt", "some data") 86 | m := help.newMap() 87 | payload := &Lstat{Path: exists} 88 | 89 | help.httpOk(help.req("lstat", payload, &m)) 90 | help.checkStatMap(m, exists) 91 | 92 | // test missing file case 93 | errCode := &ErrorCode{} 94 | payload = &Lstat{Path: help.tempPath("missing.txt")} 95 | 96 | help.httpBad(help.req("lstat", payload, errCode)) 97 | help.errorCode(errCode.Code, "ENOENT") 98 | } 99 | 100 | type readDirResult struct { 101 | Entries []string `json:"entries"` 102 | } 103 | 104 | func TestReaddir(t *testing.T) { 105 | help := Helper(t) 106 | 107 | help.createFile("exists.txt", "some data") 108 | r := &readDirResult{} 109 | payload := &Readdir{Path: help.tmpDir} 110 | 111 | help.httpOk(help.req("readdir", payload, r)) 112 | help.true(len(r.Entries) == 1, "incorrect entries length") 113 | 114 | payload = &Readdir{Path: help.tempPath("badDirectory")} 115 | help.httpBad(help.req("readdir", payload, r)) 116 | } 117 | 118 | func TestRename(t *testing.T) { 119 | help := Helper(t) 120 | existsFile := help.createFile("exists.txt", "some data") 121 | missingFile := help.tempPath("missing.txt") 122 | renameTo := help.tempPath("rename.txt") 123 | e := &ErrorCode{} 124 | payload := &Rename{From: existsFile, To: renameTo} 125 | 126 | help.httpOk(help.req("rename", payload, e)) 127 | help.exists(renameTo) 128 | 129 | // test renaming a missing file 130 | e = &ErrorCode{} 131 | payload = &Rename{From: missingFile, To: renameTo} 132 | help.httpBad(help.req("rename", payload, e)) 133 | help.errorCode(e.Code, "ENOENT") 134 | } 135 | 136 | func TestWrite(t *testing.T) { 137 | help := Helper(t) 138 | writtenFile := help.tempPath("written.txt") 139 | openMap := help.newMap() 140 | payload := &Open{Path: writtenFile, Flags: os.O_RDWR | os.O_CREATE | os.O_TRUNC, Mode: 0777} 141 | 142 | help.httpOk(help.req("open", payload, &openMap)) 143 | defer help.deferCloseFd(openMap) 144 | 145 | contents := "some sample file contents" 146 | buffer := base64.StdEncoding.EncodeToString([]byte(contents)) 147 | w := map[string]any{"fd": openMap["fd"], "buffer": buffer, "length": len(contents)} 148 | 149 | writeResult := help.newMap() 150 | help.httpOk(help.req("write", w, &writeResult)) 151 | 152 | written, ok := writeResult["written"].(float64) 153 | help.true(ok, "written value in return missing") 154 | help.true(int(written) == len(contents), "incorrect written length") 155 | } 156 | 157 | func TestWrite_with_position(t *testing.T) { 158 | help := Helper(t) 159 | writtenFile := help.createFile("writeSeek.txt", "1234567890") 160 | openMap := help.newMap() 161 | payload := &Open{Path: writtenFile, Flags: os.O_RDWR, Mode: 0777} 162 | 163 | help.httpOk(help.req("open", payload, &openMap)) 164 | 165 | contents := "ZZZ" 166 | buffer := base64.StdEncoding.EncodeToString([]byte(contents)) 167 | w := map[string]any{"fd": openMap["fd"], "position": 5, "buffer": buffer, "length": len(contents)} 168 | 169 | writeResult := help.newMap() 170 | help.httpOk(help.req("write", w, &writeResult)) 171 | 172 | written, ok := writeResult["written"].(float64) 173 | help.true(ok, "written value in return missing") 174 | help.true(int(written) == len(contents), "incorrect written length") 175 | 176 | help.httpOk(help.req("close", openMap, &ErrorCode{})) 177 | 178 | file, err := os.ReadFile(writtenFile) 179 | help.nilErr(err) 180 | help.true("12345ZZZ90" == string(file), fmt.Sprintf("expected 12345ZZZ90 but got %q", string(file))) 181 | } 182 | 183 | func TestWrite_bad(t *testing.T) { 184 | help := Helper(t) 185 | writtenFile := help.tempPath("written.txt") 186 | openMap := help.newMap() 187 | payload := &Open{Path: writtenFile, Flags: os.O_RDWR | os.O_CREATE | os.O_TRUNC, Mode: 0777} 188 | 189 | help.httpOk(help.req("open", payload, &openMap)) 190 | defer help.deferCloseFd(openMap) 191 | 192 | // failing test cases 193 | help.httpBad(help.req("write", &Write{Offset: 1}, &ErrorCode{})) 194 | help.httpBad(help.req("write", &Write{Buffer: "%%%"}, &ErrorCode{})) 195 | help.httpBad(help.req("write", &Write{Buffer: ""}, &ErrorCode{})) 196 | } 197 | 198 | func TestClose_bad(t *testing.T) { 199 | help := Helper(t) 200 | closeMap := map[string]any{"fd": math.MaxInt64} 201 | 202 | // close with bad file descriptor should error 203 | help.httpBad(help.req("close", closeMap, &ErrorCode{})) 204 | } 205 | 206 | func TestMkdir(t *testing.T) { 207 | help := Helper(t) 208 | payload := &Mkdir{Path: help.tempPath("nested")} 209 | help.httpOk(help.req("mkdir", payload, &ErrorCode{})) 210 | help.exists(payload.Path) 211 | 212 | // mkdir without parent directory should fail 213 | if runtime.GOOS != "windows" { 214 | payload = &Mkdir{Path: help.tempPath("nested/levels")} 215 | help.httpBad(help.req("mkdir", payload, &ErrorCode{})) 216 | } 217 | } 218 | 219 | func TestRmdir(t *testing.T) { 220 | help := Helper(t) 221 | payload := &Rmdir{Path: help.tempPath("nested")} 222 | help.httpOk(help.req("mkdir", payload, &ErrorCode{})) 223 | help.exists(payload.Path) 224 | 225 | help.httpOk(help.req("rmdir", payload, &ErrorCode{})) 226 | _, err := os.Stat(payload.Path) 227 | help.true(os.IsNotExist(err), "directory not removed") 228 | 229 | response := &ErrorCode{} 230 | payload.Path = help.tempPath("missing") 231 | help.httpBad(help.req("rmdir", payload, response)) 232 | 233 | } 234 | 235 | func TestUnlink(t *testing.T) { 236 | help := Helper(t) 237 | 238 | tempFile := help.createFile("delete_me.txt", "to delete") 239 | payload := &Unlink{Path: tempFile} 240 | help.httpOk(help.req("unlink", payload, &ErrorCode{})) 241 | 242 | payload = &Unlink{Path: help.tempPath("missing.txt")} 243 | help.httpBad(help.req("unlink", payload, &ErrorCode{})) 244 | } 245 | 246 | type readResult struct { 247 | Read int `json:"read"` 248 | Buffer string `json:"buffer"` 249 | } 250 | 251 | func TestRead(t *testing.T) { 252 | help := Helper(t) 253 | 254 | content := "some data" 255 | tmpFile := help.createFile("file.txt", content) 256 | m := help.newMap() 257 | help.httpOk(help.req("open", &Open{Path: tmpFile}, &m)) 258 | defer help.deferCloseFd(m) 259 | 260 | readMap := map[string]any{"fd": m["fd"], "offset": 0, "length": len(content)} 261 | resultMap := &readResult{} 262 | help.httpOk(help.req("read", readMap, &resultMap)) 263 | help.true(resultMap.Read == len(content), "read length incorrect") 264 | 265 | decodedRead, errDecode := base64.StdEncoding.DecodeString(resultMap.Buffer) 266 | help.nilErr(errDecode).true(string(decodedRead) == content, "read data did not match") 267 | 268 | readMap = map[string]any{"fd": m["fd"], "offset": 0, "position": 1, "length": len(content) - 1} 269 | help.httpOk(help.req("read", readMap, &resultMap)) 270 | decodedRead, errDecode = base64.StdEncoding.DecodeString(resultMap.Buffer) 271 | help.nilErr(errDecode).true(string(decodedRead) == content[1:], "read data did not match") 272 | 273 | readMap = map[string]any{"fd": m["fd"], "offset": 1, "length": len(content) - 1} 274 | help.httpBad(help.req("read", readMap, &resultMap)) 275 | 276 | readMap = map[string]any{"fd": m["fd"], "offset": 0, "position": -1, "length": 1} 277 | help.httpBad(help.req("read", readMap, &resultMap)) 278 | 279 | // read on bad file descriptor 280 | readMap = map[string]any{"fd": math.MaxInt64, "offset": 0, "length": len(content)} 281 | help.httpBad(help.req("read", readMap, &resultMap)) 282 | 283 | } 284 | 285 | func Test_handle(t *testing.T) { 286 | help := Helper(t) 287 | 288 | header := make(http.Header) 289 | header.Set("WBT-Token", TOKEN) 290 | u, err := url.Parse("http://localhost:12345/fs/open") 291 | help.nilErr(err) 292 | 293 | w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} 294 | request := &http.Request{URL: u, Header: header, Body: io.NopCloser(bytes.NewBufferString(""))} 295 | help.handler.handle(&Open{}, w, request) 296 | help.httpBad(w.Code) 297 | } 298 | 299 | func TestToken(t *testing.T) { 300 | help := Helper(t) 301 | 302 | u, err := url.Parse("http://localhost:12345/fs/open") 303 | help.nilErr(err) 304 | 305 | w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} 306 | request := &http.Request{URL: u, Body: io.NopCloser(bytes.NewBufferString("{}"))} 307 | help.handler.ServeHTTP(w, request) 308 | help.httpBad(w.Code) 309 | } 310 | 311 | func TestServeDefault(t *testing.T) { 312 | help := Helper(t) 313 | u, err := url.Parse("http://localhost:12345/fs/badpath") 314 | help.nilErr(err) 315 | header := make(http.Header) 316 | header.Set("WBT-Token", TOKEN) 317 | w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} 318 | request := &http.Request{URL: u, Header: header, Body: io.NopCloser(bytes.NewBufferString("{}"))} 319 | 320 | help.handler.ServeHTTP(w, request) 321 | help.httpBad(w.Code) 322 | } 323 | 324 | func Test_doError(t *testing.T) { 325 | help := Helper(t) 326 | w := &BrokenResponseRecorder{} 327 | help.handler.doError("msg", "code", w, nil) 328 | } 329 | 330 | func Test_okResponse(t *testing.T) { 331 | help := Helper(t) 332 | w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} 333 | m := help.newMap() 334 | help.handler.okResponse(m, w) 335 | help.true(w.Body.String() == "{}", "body string did not match") 336 | help.true(w.Header().Get("Content-Type") == "application/json", "bad content type header") 337 | 338 | // test case for bad serialization 339 | w = &httptest.ResponseRecorder{Body: &bytes.Buffer{}} 340 | help.handler.okResponse(&badJson{}, w) 341 | help.true(w.Body.String() == "", "body should be empty") 342 | help.true(w.Code == http.StatusInternalServerError, "bad http code") 343 | } 344 | 345 | type badJson struct { 346 | Broken func() `json:"broken"` 347 | } 348 | 349 | // END TESTS 350 | 351 | type BrokenResponseRecorder struct { 352 | httptest.ResponseRecorder 353 | } 354 | 355 | func (rw *BrokenResponseRecorder) Write(buf []byte) (int, error) { 356 | return 0, fmt.Errorf("broken pipe for data %q", string(buf)) 357 | } 358 | 359 | // Helper provides convenience methods for testing the handler. 360 | func Helper(t *testing.T) *helperApi { 361 | help := &helperApi{ 362 | t: t, 363 | m: &sync.Mutex{}, 364 | tmpDir: t.TempDir(), 365 | } 366 | var logger *log.Logger 367 | if os.Getenv("DEBUG_FS_HANDLER") != "" { 368 | logger = log.New(os.Stderr, "[wasmbrowsertest]: ", log.LstdFlags|log.Lshortfile) 369 | } else { 370 | logger = log.New(io.Discard, "", 0) 371 | } 372 | help.handler = NewHandler(TOKEN, logger) 373 | help.handler.debug = true 374 | help.tmpDir = t.TempDir() 375 | return help 376 | } 377 | 378 | type helperApi struct { 379 | t *testing.T 380 | m *sync.Mutex 381 | handler *Handler 382 | tmpDir string 383 | } 384 | 385 | func (h *helperApi) req(path string, payload any, response any) (code int) { 386 | h.t.Helper() 387 | 388 | u, err := url.Parse("http://localhost:12345/fs/" + path) 389 | h.nilErr(err) 390 | header := make(http.Header) 391 | header.Set("WBT-Token", TOKEN) 392 | 393 | body, err := json.Marshal(payload) 394 | h.nilErr(err) 395 | 396 | req := &http.Request{URL: u, Header: header, Body: io.NopCloser(bytes.NewReader(body))} 397 | w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} 398 | h.handler.ServeHTTP(w, req) 399 | 400 | err = json.Unmarshal(w.Body.Bytes(), response) 401 | h.nilErr(err) 402 | return w.Code 403 | } 404 | 405 | func (h *helperApi) tempPath(path string) string { 406 | h.t.Helper() 407 | return filepath.Join(h.tmpDir, path) 408 | } 409 | 410 | func (h *helperApi) createFile(path string, contents string) string { 411 | h.t.Helper() 412 | filePath := h.tempPath(path) 413 | h.nilErr(os.WriteFile(filePath, []byte(contents), 0644)) 414 | return filePath 415 | } 416 | 417 | func (h *helperApi) nilErr(err error) *helperApi { 418 | h.t.Helper() 419 | if err != nil { 420 | h.t.Fatal(err) 421 | } 422 | return h 423 | } 424 | 425 | func (h *helperApi) exists(path string) { 426 | h.t.Helper() 427 | _, err := os.Stat(path) 428 | h.true(err == nil, fmt.Sprintf("path %s does not exist", path)) 429 | } 430 | 431 | func (h *helperApi) httpOk(code int) *helperApi { 432 | h.t.Helper() 433 | if code != http.StatusOK { 434 | h.t.Fatalf("incorrect http code %d - expected 200", code) 435 | } 436 | return h 437 | } 438 | 439 | func (h *helperApi) httpBad(code int) *helperApi { 440 | h.t.Helper() 441 | if code != http.StatusBadRequest { 442 | h.t.Fatalf("incorrect http code %d - expected 400", code) 443 | } 444 | return h 445 | } 446 | 447 | func (h *helperApi) errorCode(actual, expected string) *helperApi { 448 | h.t.Helper() 449 | if actual != expected { 450 | h.t.Fatalf("incorrect error code %q - expected %q", actual, expected) 451 | } 452 | return h 453 | } 454 | 455 | func (h *helperApi) true(condition bool, message string) *helperApi { 456 | h.t.Helper() 457 | if !condition { 458 | h.t.Fatal(message) 459 | } 460 | return h 461 | } 462 | 463 | func (h *helperApi) newMap() map[string]any { 464 | return map[string]any{} 465 | } 466 | 467 | func (h *helperApi) checkStatMap(m map[string]any, path string) { 468 | h.t.Helper() 469 | 470 | stat, err := os.Stat(path) 471 | h.nilErr(err) 472 | 473 | if size, ok := m["size"].(int64); ok { 474 | if size != stat.Size() { 475 | h.t.Fatal("got incorrect size") 476 | } 477 | } 478 | if mTime, ok := m["mtimeMs"].(int64); ok { 479 | if mTime != stat.ModTime().UnixMilli() { 480 | h.t.Fatal("got incorrect mtimeMs") 481 | } 482 | } 483 | if mode, ok := m["mode"].(int64); ok { 484 | if mode != int64(stat.Mode()) { 485 | h.t.Fatal("got incorrect mode") 486 | } 487 | } 488 | } 489 | 490 | // sysOpen returns a file descriptor for an existing file and a function to close it. 491 | // The return type is any to support both int and windows descriptors. 492 | func (h *helperApi) sysOpen(path string) (result any, deferClose func()) { 493 | h.t.Helper() 494 | fd, err := syscall.Open(path, 0, 0) 495 | h.nilErr(err) 496 | return fd, func() { 497 | errClose := syscall.Close(fd) 498 | if errClose != nil { 499 | h.t.Fatal(errClose) 500 | } 501 | } 502 | } 503 | 504 | func (h *helperApi) deferCloseFd(m map[string]any) { 505 | h.t.Helper() 506 | h.httpOk(h.req("close", m, &ErrorCode{})) 507 | } 508 | -------------------------------------------------------------------------------- /filesys/stat.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || linux 2 | 3 | package filesys 4 | 5 | import ( 6 | "net/http" 7 | "syscall" 8 | ) 9 | 10 | func (st *Stat) WriteResponse(fa *Handler, w http.ResponseWriter) { 11 | s := &syscall.Stat_t{} 12 | err := syscall.Stat(fixPath(st.Path), s) 13 | if fa.handleError(w, err, true) { 14 | return 15 | } 16 | fa.okResponse(mapOfStatT(s), w) 17 | } 18 | 19 | func (f *Fstat) WriteResponse(fa *Handler, w http.ResponseWriter) { 20 | s := &syscall.Stat_t{} 21 | err := syscall.Fstat(f.Fd, s) 22 | if fa.handleError(w, err, false) { 23 | return 24 | } 25 | fa.okResponse(mapOfStatT(s), w) 26 | } 27 | 28 | func (ls *Lstat) WriteResponse(fa *Handler, w http.ResponseWriter) { 29 | s := &syscall.Stat_t{} 30 | err := syscall.Lstat(fixPath(ls.Path), s) 31 | if fa.handleError(w, err, true) { 32 | return 33 | } 34 | fa.okResponse(mapOfStatT(s), w) 35 | } 36 | -------------------------------------------------------------------------------- /filesys/stat_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package filesys 4 | 5 | import ( 6 | "io/fs" 7 | "net/http" 8 | "os" 9 | "syscall" 10 | ) 11 | 12 | func (st *Stat) WriteResponse(fa *Handler, w http.ResponseWriter) { 13 | stat, err := os.Stat(fixPath(st.Path)) 14 | if fa.handleError(w, err, true) { 15 | return 16 | } 17 | fa.okResponse(mapOfFileInfo(stat), w) 18 | } 19 | 20 | func (f *Fstat) WriteResponse(fa *Handler, w http.ResponseWriter) { 21 | fileInfo := &syscall.ByHandleFileInformation{} 22 | err := syscall.GetFileInformationByHandle(FdType(f.Fd), fileInfo) 23 | if fa.handleError(w, err, true) { 24 | return 25 | } 26 | fa.okResponse(mapOfByHandleFileInformation(fileInfo), w) 27 | } 28 | 29 | func (ls *Lstat) WriteResponse(fa *Handler, w http.ResponseWriter) { 30 | stat, err := os.Stat(fixPath(ls.Path)) 31 | if fa.handleError(w, err, true) { 32 | return 33 | } 34 | fa.okResponse(mapOfFileInfo(stat), w) 35 | } 36 | 37 | func mapOfFileInfo(s os.FileInfo) map[string]any { 38 | mode := s.Mode() & fs.ModePerm 39 | if s.IsDir() { 40 | mode |= 1 << 14 41 | } 42 | return map[string]any{ 43 | "dev": 0, "ino": 0, "mode": mode, 44 | "nlink": 0, "uid": 1000, "gid": 1000, 45 | "rdev": 0, "size": s.Size(), "blksize": 0, 46 | "blocks": 0, "atimeMs": s.ModTime().UnixMilli(), 47 | "mtimeMs": s.ModTime().UnixMilli(), "ctimeMs": s.ModTime().UnixMilli(), 48 | } 49 | } 50 | 51 | func mapOfByHandleFileInformation(s *syscall.ByHandleFileInformation) map[string]any { 52 | size := int64(s.FileSizeHigh)<<32 + int64(s.FileSizeLow) 53 | var mode os.FileMode 54 | if s.FileAttributes&syscall.FILE_ATTRIBUTE_READONLY != 0 { 55 | mode |= 0444 56 | } else { 57 | mode |= 0666 58 | } 59 | if s.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 { 60 | mode |= 1 << 14 61 | } 62 | 63 | nsToMs := func(ft syscall.Filetime) int64 { 64 | return ft.Nanoseconds() / 1e6 65 | } 66 | return map[string]any{ 67 | "dev": 0, "ino": 0, "mode": mode, 68 | "nlink": 0, "uid": 1000, "gid": 1000, 69 | "rdev": 0, "size": size, "blksize": 0, 70 | "blocks": 0, "atimeMs": nsToMs(s.LastAccessTime), 71 | "mtimeMs": nsToMs(s.LastWriteTime), "ctimeMs": nsToMs(s.CreationTime), 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /filesys/statmap_darwin.go: -------------------------------------------------------------------------------- 1 | package filesys 2 | 3 | import "syscall" 4 | 5 | func mapOfStatT(s *syscall.Stat_t) map[string]any { 6 | 7 | toMs := func(ts syscall.Timespec) int64 { return ts.Sec*1000 + ts.Nsec/1e6 } 8 | 9 | // https://github.com/golang/go/blob/c19c4c566c63818dfd059b352e52c4710eecf14d/src/syscall/fs_js.go#L165 10 | return map[string]any{ 11 | "dev": s.Dev, "ino": s.Ino, "mode": s.Mode, 12 | "nlink": s.Nlink, "uid": s.Uid, "gid": s.Gid, 13 | "rdev": s.Rdev, "size": s.Size, "blksize": s.Blksize, 14 | "blocks": s.Blocks, "atimeMs": toMs(s.Atimespec), 15 | "mtimeMs": toMs(s.Mtimespec), "ctimeMs": toMs(s.Ctimespec), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /filesys/statmap_linux.go: -------------------------------------------------------------------------------- 1 | package filesys 2 | 3 | import "syscall" 4 | 5 | func mapOfStatT(s *syscall.Stat_t) map[string]any { 6 | 7 | toMs := func(ts syscall.Timespec) int64 { return ts.Sec*1000 + ts.Nsec/1e6 } 8 | 9 | // https://github.com/golang/go/blob/c19c4c566c63818dfd059b352e52c4710eecf14d/src/syscall/fs_js.go#L165 10 | return map[string]any{ 11 | "dev": s.Dev, "ino": s.Ino, "mode": s.Mode, 12 | "nlink": s.Nlink, "uid": s.Uid, "gid": s.Gid, 13 | "rdev": s.Rdev, "size": s.Size, "blksize": s.Blksize, 14 | "blocks": s.Blocks, "atimeMs": toMs(s.Atim), 15 | "mtimeMs": toMs(s.Mtim), "ctimeMs": toMs(s.Ctim), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /func_map.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/go-interpreter/wagon/wasm" 7 | ) 8 | 9 | func getFuncMap(wasmFile string) (map[int]string, error) { 10 | funcMap := make(map[int]string) 11 | wasmFd, err := os.Open(wasmFile) 12 | if err != nil { 13 | return funcMap, err 14 | } 15 | defer wasmFd.Close() 16 | mod, err := wasm.ReadModule(wasmFd, nil) 17 | if err != nil { 18 | return funcMap, err 19 | } 20 | 21 | // populating imports 22 | counter := 0 23 | for i, e := range mod.Import.Entries { 24 | funcMap[i] = e.FieldName 25 | counter++ 26 | } 27 | // Skipping the imported functions 28 | for i, f := range mod.FunctionIndexSpace[counter:] { 29 | funcMap[i] = f.Name 30 | } 31 | return funcMap, nil 32 | } 33 | -------------------------------------------------------------------------------- /func_map_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestFuncMap(t *testing.T) { 6 | fMap, err := getFuncMap("testdata/test.wasm") 7 | if err != nil { 8 | t.Fatal(err) 9 | } 10 | tests := []struct { 11 | index int 12 | name string 13 | }{ 14 | {0, "go.buildid"}, 15 | {10, "sync_atomic.LoadUint64"}, 16 | {100, "runtime.cgoCheckBits"}, 17 | } 18 | for i, test := range tests { 19 | if fMap[test.index] != test.name { 20 | t.Errorf("[%d] incorrect function name; expected %q, got %q.", i, test.name, fMap[test.index]) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/agnivade/wasmbrowsertest 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 7 | github.com/chromedp/chromedp v0.10.0 8 | github.com/go-interpreter/wagon v0.6.0 9 | github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 10 | ) 11 | 12 | require ( 13 | github.com/chromedp/sysutil v1.0.0 // indirect 14 | github.com/gobwas/httphead v0.1.0 // indirect 15 | github.com/gobwas/pool v0.2.1 // indirect 16 | github.com/gobwas/ws v1.4.0 // indirect 17 | github.com/josharian/intern v1.0.0 // indirect 18 | github.com/mailru/easyjson v0.7.7 // indirect 19 | golang.org/x/sys v0.24.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 h1:bATMoZLH2QGct1kzDxfmeBUQI/QhQvB0mBrOTct+YlQ= 2 | github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= 3 | github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E= 4 | github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE= 5 | github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= 6 | github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= 7 | github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= 8 | github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= 9 | github.com/go-interpreter/wagon v0.6.0 h1:BBxDxjiJiHgw9EdkYXAWs8NHhwnazZ5P2EWBW5hFNWw= 10 | github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc= 11 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 12 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 13 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 14 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 15 | github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 16 | github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 17 | github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= 18 | github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= 19 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 20 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 21 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= 22 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 23 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 24 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 25 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= 26 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 27 | github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc h1:RTUQlKzoZZVG3umWNzOYeFecQLIh+dbxXvJp1zPQJTI= 28 | github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= 29 | golang.org/x/sys v0.0.0-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 30 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 32 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 33 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 34 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | _ "embed" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "html/template" 10 | "io" 11 | "log" 12 | "net/http" 13 | "os" 14 | "path/filepath" 15 | "runtime" 16 | "strconv" 17 | "strings" 18 | "time" 19 | 20 | "github.com/agnivade/wasmbrowsertest/filesys" 21 | ) 22 | 23 | //go:embed index.html 24 | var indexHTML string 25 | 26 | type wasmServer struct { 27 | indexTmpl *template.Template 28 | wasmFile string 29 | wasmExecJS []byte 30 | args []string 31 | envMap map[string]string 32 | logger *log.Logger 33 | fsHandler *filesys.Handler 34 | securityToken string 35 | } 36 | 37 | var wasmLocations = []string{ 38 | "misc/wasm/wasm_exec.js", 39 | "lib/wasm/wasm_exec.js", 40 | } 41 | 42 | func NewWASMServer(wasmFile string, args []string, coverageFile string, l *log.Logger) (http.Handler, error) { 43 | var err error 44 | srv := &wasmServer{ 45 | wasmFile: wasmFile, 46 | args: args, 47 | logger: l, 48 | envMap: make(map[string]string), 49 | } 50 | 51 | // try for some security on an api capable of 52 | // reads and writes to the file system 53 | srv.securityToken, err = generateToken() 54 | if err != nil { 55 | return nil, err 56 | } 57 | srv.fsHandler = filesys.NewHandler(srv.securityToken, l) 58 | 59 | for _, env := range os.Environ() { 60 | vars := strings.SplitN(env, "=", 2) 61 | srv.envMap[vars[0]] = vars[1] 62 | } 63 | 64 | var buf []byte 65 | for _, loc := range wasmLocations { 66 | buf, err = os.ReadFile(filepath.Join(runtime.GOROOT(), loc)) 67 | if err == nil { 68 | break 69 | } 70 | if !os.IsNotExist(err) { 71 | return nil, err 72 | } 73 | } 74 | if err != nil { 75 | var perr *os.PathError 76 | if errors.As(err, &perr) { 77 | if strings.Contains(perr.Path, filepath.Join("golang.org", "toolchain")) { 78 | return nil, fmt.Errorf("The Go toolchain does not include the WebAssembly exec helper before Go 1.24. Please copy wasm_exec.js to %s", filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js")) 79 | } 80 | } 81 | return nil, err 82 | } 83 | srv.wasmExecJS = buf 84 | 85 | srv.indexTmpl, err = template.New("index").Parse(indexHTML) 86 | if err != nil { 87 | return nil, err 88 | } 89 | return srv, nil 90 | } 91 | 92 | func (ws *wasmServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 93 | // log.Println(r.URL.Path) 94 | switch r.URL.Path { 95 | case "/", "/index.html": 96 | w.Header().Set("Content-Type", "text/html; charset=UTF-8") 97 | data := struct { 98 | WASMFile string 99 | Args []string 100 | EnvMap map[string]string 101 | SecurityToken string 102 | Pid int 103 | Ppid int 104 | }{ 105 | WASMFile: filepath.Base(ws.wasmFile), 106 | Args: ws.args, 107 | EnvMap: ws.envMap, 108 | SecurityToken: ws.securityToken, 109 | Pid: os.Getpid(), 110 | Ppid: os.Getppid(), 111 | } 112 | err := ws.indexTmpl.Execute(w, data) 113 | if err != nil { 114 | ws.logger.Println(err) 115 | } 116 | case "/" + filepath.Base(ws.wasmFile): 117 | f, err := os.Open(ws.wasmFile) 118 | if err != nil { 119 | ws.logger.Println(err) 120 | return 121 | } 122 | defer func() { 123 | err := f.Close() 124 | if err != nil { 125 | ws.logger.Println(err) 126 | } 127 | }() 128 | http.ServeContent(w, r, r.URL.Path, time.Now(), f) 129 | case "/wasm_exec.js": 130 | w.Header().Set("Content-Type", "application/javascript") 131 | w.Header().Set("Content-Length", strconv.Itoa(len(ws.wasmExecJS))) 132 | if _, err := w.Write(ws.wasmExecJS); err != nil { 133 | ws.logger.Println("unable to write wasm_exec.") 134 | } 135 | default: 136 | if strings.HasPrefix(r.URL.Path, "/fs/") { 137 | ws.fsHandler.ServeHTTP(w, r) 138 | } 139 | } 140 | } 141 | 142 | func generateToken() (string, error) { 143 | buf := make([]byte, 32) 144 | if _, err := io.ReadFull(rand.Reader, buf); err != nil { 145 | return "", err 146 | } 147 | return base64.StdEncoding.EncodeToString(buf), nil 148 | } 149 | -------------------------------------------------------------------------------- /http_server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net" 7 | "net/http" 8 | neturl "net/url" 9 | "time" 10 | ) 11 | 12 | func startHTTPServer(ctx context.Context, handler http.Handler, logger *log.Logger) (url string, shutdown context.CancelFunc, err error) { 13 | // Need to generate a random port every time for tests in parallel to run. 14 | l, err := net.Listen("tcp", "localhost:") 15 | if err != nil { 16 | return "", nil, err 17 | } 18 | 19 | server := &http.Server{ 20 | Handler: handler, 21 | } 22 | go func() { // serves HTTP 23 | err := server.Serve(l) 24 | if err != http.ErrServerClosed { 25 | logger.Println(err) 26 | } 27 | }() 28 | 29 | shutdownCtx, startShutdown := context.WithCancel(ctx) 30 | shutdownComplete := make(chan struct{}, 1) 31 | go func() { // waits for canceled ctx or triggered shutdown, then shuts down HTTP 32 | <-shutdownCtx.Done() 33 | shutdownTimeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 34 | defer cancel() 35 | err := server.Shutdown(shutdownTimeoutCtx) 36 | if err != nil { 37 | logger.Println(err) 38 | } 39 | shutdownComplete <- struct{}{} 40 | }() 41 | 42 | shutdown = func() { 43 | startShutdown() 44 | <-shutdownComplete 45 | } 46 | url = (&neturl.URL{ 47 | Scheme: "http", 48 | Host: l.Addr().String(), 49 | }).String() 50 | return url, shutdown, nil 51 | } 52 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | Go wasm 12 | 13 | 14 | 15 | 20 | 21 | 167 | 168 | 169 | 170 | 171 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "os" 12 | "os/signal" 13 | "path" 14 | "runtime" 15 | "strconv" 16 | "strings" 17 | 18 | "github.com/chromedp/cdproto/inspector" 19 | "github.com/chromedp/cdproto/profiler" 20 | cdpruntime "github.com/chromedp/cdproto/runtime" 21 | "github.com/chromedp/cdproto/target" 22 | "github.com/chromedp/chromedp" 23 | ) 24 | 25 | func main() { 26 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 27 | defer cancel() 28 | err := run(ctx, os.Args, os.Stderr, flag.CommandLine) 29 | if err != nil { 30 | fmt.Fprintln(os.Stderr, err) 31 | os.Exit(1) 32 | } 33 | } 34 | 35 | func run(ctx context.Context, args []string, errOutput io.Writer, flagSet *flag.FlagSet) (returnedErr error) { 36 | logger := log.New(errOutput, "[wasmbrowsertest]: ", log.LstdFlags|log.Lshortfile) 37 | defer func() { 38 | r := recover() 39 | if r != nil { 40 | returnedErr = fmt.Errorf("Panicked: %+v", r) 41 | } 42 | }() 43 | 44 | if len(args) < 2 { 45 | return errors.New("Please pass a wasm file as a parameter") 46 | } 47 | 48 | cpuProfile := flagSet.String("test.cpuprofile", "", "") 49 | coverageProfile := flagSet.String("test.coverprofile", "", "") 50 | 51 | wasmFile := args[1] 52 | ext := path.Ext(wasmFile) 53 | // net/http code does not take js/wasm path if it is a .test binary. 54 | if ext == ".test" { 55 | wasmFile = strings.Replace(wasmFile, ext, ".wasm", -1) 56 | err := copyFile(args[1], wasmFile) 57 | if err != nil { 58 | return err 59 | } 60 | defer os.Remove(wasmFile) 61 | args[1] = wasmFile 62 | } 63 | 64 | passon, err := gentleParse(flagSet, args[2:]) 65 | if err != nil { 66 | return err 67 | } 68 | passon = append([]string{wasmFile}, passon...) 69 | if *coverageProfile != "" { 70 | passon = append(passon, "-test.coverprofile="+*coverageProfile) 71 | } 72 | 73 | // Setup web server. 74 | handler, err := NewWASMServer(wasmFile, passon, *coverageProfile, logger) 75 | if err != nil { 76 | return err 77 | } 78 | url, shutdownHTTPServer, err := startHTTPServer(ctx, handler, logger) 79 | if err != nil { 80 | return err 81 | } 82 | defer shutdownHTTPServer() 83 | 84 | opts := chromedp.DefaultExecAllocatorOptions[:] 85 | if os.Getenv("WASM_HEADLESS") == "off" { 86 | opts = append(opts, 87 | chromedp.Flag("headless", false), 88 | ) 89 | } 90 | 91 | // WSL needs the GPU disabled. See issue #10 92 | if runtime.GOOS == "linux" && isWSL() { 93 | opts = append(opts, 94 | chromedp.DisableGPU, 95 | ) 96 | } 97 | 98 | // create chrome instance 99 | allocCtx, cancelAllocCtx := chromedp.NewExecAllocator(ctx, opts...) 100 | defer cancelAllocCtx() 101 | ctx, cancelCtx := chromedp.NewContext(allocCtx) 102 | defer cancelCtx() 103 | 104 | chromedp.ListenTarget(ctx, func(ev interface{}) { 105 | handleEvent(ctx, ev, logger) 106 | }) 107 | 108 | var exitCode int 109 | tasks := []chromedp.Action{ 110 | chromedp.Navigate(url), 111 | chromedp.WaitEnabled(`#doneButton`), 112 | chromedp.Evaluate(`exitCode;`, &exitCode), 113 | } 114 | if *cpuProfile != "" { 115 | // Prepend and append profiling tasks 116 | tasks = append([]chromedp.Action{ 117 | profiler.Enable(), 118 | profiler.Start(), 119 | }, tasks...) 120 | tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error { 121 | profile, err := profiler.Stop().Do(ctx) 122 | if err != nil { 123 | return err 124 | } 125 | outF, err := os.Create(*cpuProfile) 126 | if err != nil { 127 | return err 128 | } 129 | defer func() { 130 | err = outF.Close() 131 | if err != nil { 132 | logger.Println(err) 133 | } 134 | }() 135 | 136 | funcMap, err := getFuncMap(wasmFile) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | return WriteProfile(profile, outF, funcMap) 142 | })) 143 | } 144 | 145 | err = chromedp.Run(ctx, tasks...) 146 | if err != nil { 147 | // Browser did not exit cleanly. Likely failed with an uncaught error. 148 | return err 149 | } 150 | if exitCode != 0 { 151 | return fmt.Errorf("exit with status %d", exitCode) 152 | } 153 | return nil 154 | } 155 | 156 | func copyFile(src, dst string) error { 157 | srdFd, err := os.Open(src) 158 | if err != nil { 159 | return fmt.Errorf("error in copying %s to %s: %v", src, dst, err) 160 | } 161 | defer srdFd.Close() 162 | 163 | dstFd, err := os.Create(dst) 164 | if err != nil { 165 | return fmt.Errorf("error in copying %s to %s: %v", src, dst, err) 166 | } 167 | defer dstFd.Close() 168 | _, err = io.Copy(dstFd, srdFd) 169 | if err != nil { 170 | return fmt.Errorf("error in copying %s to %s: %v", src, dst, err) 171 | } 172 | return nil 173 | } 174 | 175 | // handleEvent responds to different events from the browser and takes 176 | // appropriate action. 177 | func handleEvent(ctx context.Context, ev interface{}, logger *log.Logger) { 178 | switch ev := ev.(type) { 179 | case *cdpruntime.EventConsoleAPICalled: 180 | for _, arg := range ev.Args { 181 | line := string(arg.Value) 182 | if line == "" { // If Value is not found, look for Description. 183 | line = arg.Description 184 | } 185 | // Any string content is quoted with double-quotes. 186 | // So need to treat it specially. 187 | s, err := strconv.Unquote(line) 188 | if err != nil { 189 | // Probably some numeric content, print it as is. 190 | fmt.Printf("%s\n", line) 191 | continue 192 | } 193 | fmt.Printf("%s\n", s) 194 | } 195 | case *cdpruntime.EventExceptionThrown: 196 | if ev.ExceptionDetails != nil { 197 | details := ev.ExceptionDetails 198 | fmt.Printf("%s:%d:%d %s\n", details.URL, details.LineNumber, details.ColumnNumber, details.Text) 199 | if details.Exception != nil { 200 | fmt.Printf("%s\n", details.Exception.Description) 201 | } 202 | } 203 | case *target.EventTargetCrashed: 204 | fmt.Printf("target crashed: status: %s, error code:%d\n", ev.Status, ev.ErrorCode) 205 | err := chromedp.Cancel(ctx) 206 | if err != nil { 207 | logger.Printf("error in cancelling context: %v\n", err) 208 | } 209 | case *inspector.EventDetached: 210 | fmt.Println("inspector detached: ", ev.Reason) 211 | err := chromedp.Cancel(ctx) 212 | if err != nil { 213 | logger.Printf("error in cancelling context: %v\n", err) 214 | } 215 | } 216 | } 217 | 218 | // isWSL returns true if the OS is WSL, false otherwise. 219 | // This method of checking for WSL has worked since mid 2016: 220 | // https://github.com/microsoft/WSL/issues/423#issuecomment-328526847 221 | func isWSL() bool { 222 | buf, err := os.ReadFile("/proc/sys/kernel/osrelease") 223 | if err != nil { 224 | return false 225 | } 226 | // if there was an error opening the file it must not be WSL, so ignore the error 227 | return bytes.Contains(buf, []byte("Microsoft")) 228 | } 229 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "flag" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "testing" 11 | ) 12 | 13 | func TestRun(t *testing.T) { 14 | t.Skip("Too flaky. See: https://github.com/agnivade/wasmbrowsertest/issues/59") 15 | for _, tc := range []struct { 16 | description string 17 | files map[string]string 18 | args []string 19 | expectErr string 20 | }{ 21 | { 22 | description: "handle panic", 23 | files: map[string]string{ 24 | "go.mod": ` 25 | module foo 26 | 27 | go 1.20 28 | `, 29 | "foo.go": ` 30 | package main 31 | 32 | func main() { 33 | panic("failed") 34 | } 35 | `, 36 | }, 37 | expectErr: "exit with status 2", 38 | }, 39 | { 40 | description: "handle panic in next run of event loop", 41 | files: map[string]string{ 42 | "go.mod": ` 43 | module foo 44 | 45 | go 1.20 46 | `, 47 | "foo.go": ` 48 | package main 49 | 50 | import ( 51 | "syscall/js" 52 | ) 53 | 54 | func main() { 55 | js.Global().Call("setTimeout", js.FuncOf(func(js.Value, []js.Value) any { 56 | panic("bad") 57 | return nil 58 | }), 0) 59 | } 60 | `, 61 | }, 62 | expectErr: "", 63 | }, 64 | { 65 | description: "handle callback after test exit", 66 | files: map[string]string{ 67 | "go.mod": ` 68 | module foo 69 | 70 | go 1.20 71 | `, 72 | "foo.go": ` 73 | package main 74 | 75 | import ( 76 | "syscall/js" 77 | "fmt" 78 | ) 79 | 80 | func main() { 81 | js.Global().Call("setInterval", js.FuncOf(func(js.Value, []js.Value) any { 82 | fmt.Println("callback") 83 | return nil 84 | }), 5) 85 | fmt.Println("done") 86 | } 87 | `, 88 | }, 89 | expectErr: "", 90 | }, 91 | } { 92 | t.Run(tc.description, func(t *testing.T) { 93 | dir := t.TempDir() 94 | for fileName, contents := range tc.files { 95 | writeFile(t, dir, fileName, contents) 96 | } 97 | wasmFile := buildTestWasm(t, dir) 98 | _, err := testRun(t, wasmFile, tc.args...) 99 | assertEqualError(t, tc.expectErr, err) 100 | }) 101 | } 102 | } 103 | 104 | func testRun(t *testing.T, wasmFile string, flags ...string) ([]byte, error) { 105 | var logs bytes.Buffer 106 | flagSet := flag.NewFlagSet("wasmbrowsertest", flag.ContinueOnError) 107 | ctx, cancel := context.WithCancel(context.Background()) 108 | t.Cleanup(cancel) 109 | 110 | err := run(ctx, append([]string{"go_js_wasm_exec", wasmFile}, flags...), &logs, flagSet) 111 | return logs.Bytes(), err 112 | } 113 | 114 | // writeFile creates a file at $baseDir/$path with the given contents, where 'path' is slash separated 115 | func writeFile(t *testing.T, baseDir, path, contents string) { 116 | t.Helper() 117 | path = filepath.FromSlash(path) 118 | fullPath := filepath.Join(baseDir, path) 119 | err := os.MkdirAll(filepath.Dir(fullPath), 0755) 120 | if err != nil { 121 | t.Fatal("Failed to create file's base directory:", err) 122 | } 123 | err = os.WriteFile(fullPath, []byte(contents), 0600) 124 | if err != nil { 125 | t.Fatal("Failed to create file:", err) 126 | } 127 | } 128 | 129 | // buildTestWasm builds the given Go package's test binary and returns the output Wasm file 130 | func buildTestWasm(t *testing.T, path string) string { 131 | t.Helper() 132 | outputFile := filepath.Join(t.TempDir(), "out.wasm") 133 | cmd := exec.Command("go", "build", "-o", outputFile, ".") 134 | cmd.Dir = path 135 | cmd.Env = append(os.Environ(), 136 | "GOOS=js", 137 | "GOARCH=wasm", 138 | ) 139 | output, err := cmd.CombinedOutput() 140 | if len(output) > 0 { 141 | t.Log(string(output)) 142 | } 143 | if err != nil { 144 | t.Fatal("Failed to build Wasm binary:", err) 145 | } 146 | return outputFile 147 | } 148 | 149 | func assertEqualError(t *testing.T, expected string, err error) { 150 | t.Helper() 151 | if expected == "" { 152 | if err != nil { 153 | t.Error("Unexpected error:", err) 154 | } 155 | return 156 | } 157 | 158 | if err == nil { 159 | t.Error("Expected error, got nil") 160 | return 161 | } 162 | message := err.Error() 163 | if expected != message { 164 | t.Errorf("Unexpected error message: %q != %q", expected, message) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | // gentleParse takes a flag.FlagSet, calls Parse to get its flags parsed, 11 | // and collects the arguments the FlagSet does not recognize, returning 12 | // the collected list. 13 | func gentleParse(flagset *flag.FlagSet, args []string) ([]string, error) { 14 | if len(args) == 0 { 15 | return nil, nil 16 | } 17 | const prefix = "flag provided but not defined: " 18 | 19 | r := make([]string, 0, len(args)) 20 | 21 | flagset.Init(flagset.Name(), flag.ContinueOnError) 22 | w := flagset.Output() 23 | flagset.SetOutput(io.Discard) 24 | 25 | // Put back the flagset's output, the flagset's Usage might be called later. 26 | defer flagset.SetOutput(w) 27 | 28 | next := args 29 | 30 | for len(next) > 0 { 31 | if !strings.HasPrefix(next[0], "-") { 32 | r, next = append(r, next[0]), next[1:] 33 | continue 34 | } 35 | if err := flagset.Parse(next); err != nil { 36 | if strings.HasPrefix(err.Error(), prefix) { 37 | pull := strings.TrimPrefix(err.Error(), prefix) 38 | for next[0] != pull && !(strings.HasPrefix(next[0], pull) && strings.HasPrefix(next[0], pull+"=")) { 39 | next = next[1:] 40 | if len(next) == 0 { 41 | panic("odd: pull not found: " + pull) 42 | } 43 | } 44 | r, next = append(r, next[0]), next[1:] 45 | continue 46 | } 47 | fmt.Fprintf(w, "%s\n", err) 48 | flagset.SetOutput(w) 49 | flagset.Usage() 50 | return nil, err 51 | } 52 | 53 | // Check if the call to flagset.Parse ate a "--". If so, we're done 54 | // and can return what's been built up on r along with the rest. 55 | if len(next) > len(flagset.Args()) { 56 | lastabsorbedpos := len(next) - len(flagset.Args()) - 1 57 | lastabsorbed := next[lastabsorbedpos] 58 | if lastabsorbed == "--" { 59 | r = append(r, "--") // return the "--" too. 60 | return append(r, flagset.Args()...), nil 61 | } 62 | } 63 | next = flagset.Args() 64 | } 65 | return r, nil 66 | } 67 | -------------------------------------------------------------------------------- /parse_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | // The forms of these tests for the gentleParse function 12 | // are generally that the args are presented to the testParse function 13 | // and the expected results are presented as separate lines to the 14 | // Expect result. 15 | 16 | func TestParse(t *testing.T) { 17 | t.Run("Empty", func(t *testing.T) { 18 | // Empty in, empty out. 19 | testParse(t).Expect(t, 20 | `cpuProfile: ""`, 21 | `passon : []`, 22 | ) 23 | }) 24 | 25 | t.Run("Other", func(t *testing.T) { 26 | // Empty in, empty out, with an extra `other` variable that has a default. 27 | testParseOther(t).Expect(t, 28 | `cpuProfile: ""`, 29 | `other : "default-other-value"`, 30 | `passon : []`, 31 | ) 32 | 33 | // Test parsing of a custom flag with a custom value 34 | testParseOther(t, "-test.v", "-test.cpuprofile", "cpu1.out", "-other=another").Expect(t, 35 | `cpuProfile: "cpu1.out"`, 36 | `other : "another"`, 37 | `passon : ["-test.v"]`, 38 | ) 39 | }) 40 | 41 | t.Run("Verbose", func(t *testing.T) { 42 | // One unrecognized in, same out. 43 | testParse(t, "-test.v").Expect(t, 44 | `cpuProfile: ""`, 45 | `passon : ["-test.v"]`, 46 | ) 47 | }) 48 | 49 | t.Run("CPU1", func(t *testing.T) { 50 | // One unrecognized followed by ours. 51 | testParse(t, "-test.v", "-test.cpuprofile", "cpu1.out").Expect(t, 52 | `cpuProfile: "cpu1.out"`, 53 | `passon : ["-test.v"]`, 54 | ) 55 | }) 56 | 57 | t.Run("CPU2", func(t *testing.T) { 58 | // Ours followed by one unrecognized. 59 | testParse(t, "-test.cpuprofile", "cpu2.out", "-test.v").Expect(t, 60 | `cpuProfile: "cpu2.out"`, 61 | `passon : ["-test.v"]`, 62 | ) 63 | }) 64 | 65 | t.Run("CPU3", func(t *testing.T) { 66 | // Ours followed by one unrecognized that uses "=". 67 | testParse(t, "-test.cpuprofile", "cpu3.out", "-test.v=true").Expect(t, 68 | `cpuProfile: "cpu3.out"`, 69 | `passon : ["-test.v=true"]`, 70 | ) 71 | }) 72 | 73 | t.Run("EqualCPU4", func(t *testing.T) { 74 | // Swapping order from Cpu3 test, the unrecognized first, followed by ours. 75 | testParse(t, "-test.v=true", "-test.cpuprofile", "cpu4.out").Expect(t, 76 | `cpuProfile: "cpu4.out"`, 77 | `passon : ["-test.v=true"]`, 78 | ) 79 | }) 80 | 81 | t.Run("ExtraBool1", func(t *testing.T) { 82 | // Ours followed by two unrecognized. 83 | testParse(t, "-test.cpuprofile", "cpu.out", "-test.v", "-bool").Expect(t, 84 | `cpuProfile: "cpu.out"`, 85 | `passon : ["-test.v" "-bool"]`, 86 | ) 87 | }) 88 | 89 | t.Run("ExtraBool2", func(t *testing.T) { 90 | // Ours between two unrecognized. 91 | testParse(t, "-bool", "-test.cpuprofile", "cpu.out", "-test.v").Expect(t, 92 | `cpuProfile: "cpu.out"`, 93 | `passon : ["-bool" "-test.v"]`, 94 | ) 95 | }) 96 | 97 | t.Run("ExtraStringNoDDash1", func(t *testing.T) { 98 | // Ours pulled out from front. 99 | testParse(t, "-test.cpuprofile", "cpu.out", "-test.v", "-bool", "-string", "last").Expect(t, 100 | `cpuProfile: "cpu.out"`, 101 | `passon : ["-test.v" "-bool" "-string" "last"]`, 102 | ) 103 | }) 104 | 105 | t.Run("ExtraStringNoDDash2", func(t *testing.T) { 106 | // Ours pulled out from middle. 107 | testParse(t, "-string", "first", "-test.cpuprofile", "cpu.out", "-test.v", "-bool").Expect(t, 108 | `cpuProfile: "cpu.out"`, 109 | `passon : ["-string" "first" "-test.v" "-bool"]`, 110 | ) 111 | }) 112 | 113 | t.Run("DDash1ExtraString", func(t *testing.T) { 114 | // Ours pulled out from front and the -- appears afterwards. 115 | testParse(t, "-test.cpuprofile", "cpu.out", "-test.v", "--", "-bool", "-string", "abc").Expect(t, 116 | `cpuProfile: "cpu.out"`, 117 | `passon : ["-test.v" "--" "-bool" "-string" "abc"]`, 118 | ) 119 | }) 120 | 121 | t.Run("DDash2ExtraString", func(t *testing.T) { 122 | // Ours pulled out from front and the -- appears afterwards. 123 | testParse(t, "-test.cpuprofile", "cpu.out", "--", "-test.v", "-bool", "-string", "abc").Expect(t, 124 | `cpuProfile: "cpu.out"`, 125 | `passon : ["--" "-test.v" "-bool" "-string" "abc"]`, 126 | ) 127 | }) 128 | 129 | t.Run("DDash3UnprocessedProfile", func(t *testing.T) { 130 | // Ours *not* pulled out because it appears after a --, just as "go test" would handle it. 131 | testParse(t, "--", "-test.cpuprofile", "cpu.other", "-test.v", "-bool", "-string", "abc").Expect(t, 132 | `cpuProfile: ""`, 133 | `passon : ["--" "-test.cpuprofile" "cpu.other" "-test.v" "-bool" "-string" "abc"]`, 134 | ) 135 | }) 136 | } 137 | 138 | type testParseGot struct { 139 | got []string 140 | } 141 | 142 | func makeParseGot(lines ...string) testParseGot { 143 | return testParseGot{got: lines} 144 | } 145 | 146 | func (g testParseGot) failure(expect []string, format string, args ...interface{}) string { 147 | buf := new(strings.Builder) 148 | fmt.Fprintf(buf, format+"\n", args...) 149 | fmt.Fprintf(buf, " Got:\n") 150 | for i := range g.got { 151 | fmt.Fprintf(buf, " %s\n", g.got[i]) 152 | } 153 | fmt.Fprintf(buf, " Expected:\n") 154 | for i := range expect { 155 | fmt.Fprintf(buf, " %s\n", expect[i]) 156 | } 157 | return buf.String() 158 | } 159 | 160 | func (g testParseGot) Expect(t testing.TB, expect ...string) { 161 | if len(g.got) != len(expect) { 162 | t.Helper() 163 | t.Errorf("%s", 164 | g.failure(expect, "got %d lines, expected %d", len(g.got), len(expect))) 165 | return 166 | } 167 | for i := range g.got { 168 | if g.got[i] != expect[i] { 169 | t.Helper() 170 | t.Errorf("%s", 171 | g.failure(expect, "at least line %d of got and expected don't match", i+1)) 172 | return 173 | } 174 | } 175 | } 176 | 177 | func testParse(t *testing.T, args ...string) testParseGot { 178 | t.Helper() 179 | var ( 180 | cpuProfile string 181 | ) 182 | 183 | flagset := flag.NewFlagSet("binname", flag.ExitOnError) 184 | flagset.SetOutput(os.Stdout) // For Examples to catch as output. 185 | 186 | flagset.StringVar(&cpuProfile, "test.cpuprofile", "", "") 187 | 188 | passon, err := gentleParse(flagset, args) 189 | if err != nil { 190 | t.Error(err) 191 | } 192 | 193 | return makeParseGot( 194 | fmt.Sprintf("cpuProfile: %q", cpuProfile), 195 | fmt.Sprintf("passon : %q", passon), 196 | ) 197 | } 198 | 199 | // This one acts more like an example of how to perform a different type of test. 200 | // It was perhaps useful in early stages of building unit tests but then seems 201 | // to have gone unused except for the default, empty, case. 202 | func testParseOther(t *testing.T, args ...string) testParseGot { 203 | t.Helper() 204 | var ( 205 | cpuProfile string 206 | other string 207 | ) 208 | 209 | flagset := flag.NewFlagSet("binname", flag.ExitOnError) 210 | flagset.SetOutput(os.Stdout) // For Examples to catch as output. 211 | 212 | flagset.StringVar(&cpuProfile, "test.cpuprofile", "", "") 213 | flagset.StringVar(&other, "other", "default-other-value", "") 214 | 215 | passon, err := gentleParse(flagset, args) 216 | if err != nil { 217 | t.Error(err) 218 | } 219 | 220 | return makeParseGot( 221 | fmt.Sprintf("cpuProfile: %q", cpuProfile), 222 | fmt.Sprintf("other : %q", other), 223 | fmt.Sprintf("passon : %q", passon), 224 | ) 225 | } 226 | -------------------------------------------------------------------------------- /profiler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "regexp" 7 | "strconv" 8 | 9 | "github.com/chromedp/cdproto/profiler" 10 | "github.com/google/pprof/profile" 11 | ) 12 | 13 | // locMeta is a wrapper around profile.Location with an extra 14 | // pointer towards its parent node. 15 | type locMeta struct { 16 | loc *profile.Location 17 | parent *profile.Location 18 | } 19 | 20 | // WriteProfile converts a chromedp profile to a pprof profile. 21 | func WriteProfile(cProf *profiler.Profile, w io.Writer, funcMap map[int]string) error { 22 | // Creating an empty pprof object 23 | pProf := profile.Profile{ 24 | SampleType: []*profile.ValueType{ 25 | { 26 | Type: "samples", 27 | Unit: "count", 28 | }, 29 | { 30 | Type: "cpu", 31 | Unit: "nanoseconds", 32 | }, 33 | }, 34 | PeriodType: &profile.ValueType{ 35 | Type: "cpu", 36 | Unit: "nanoseconds", 37 | }, 38 | TimeNanos: int64(cProf.StartTime) * 1000, 39 | DurationNanos: int64(cProf.EndTime-cProf.StartTime) * 1000, 40 | } 41 | 42 | // Helper maps which allow easy construction of the profile. 43 | fnMap := make(map[string]*profile.Function) 44 | locMap := make(map[int64]locMeta) 45 | funcRegexp := regexp.MustCompile(`^wasm-function\[([0-9]+)\]$`) 46 | 47 | // A monotonically increasing function ID. 48 | // We bump this everytime we see a new function. 49 | var fnID uint64 = 1 50 | pProf.Location = make([]*profile.Location, len(cProf.Nodes)) 51 | // Now we iterate the cprof nodes and populate the functions and locations. 52 | for i, n := range cProf.Nodes { 53 | cf := n.CallFrame 54 | // We create such a function key to uniquely map functions, since the profile does not have 55 | // any unique function ID. 56 | fnKey := cf.FunctionName + strconv.Itoa(int(cf.LineNumber)) + strconv.Itoa(int(cf.ColumnNumber)) 57 | pFn, exists := fnMap[fnKey] 58 | if !exists { 59 | // If the function name is of form wasm-function[], then we find out the actual function name 60 | // from the passed map and replace the name. 61 | if funcRegexp.MatchString(cf.FunctionName) { 62 | fIndex, err := strconv.Atoi(funcRegexp.FindStringSubmatch(cf.FunctionName)[1]) 63 | if err != nil { 64 | return fmt.Errorf("incorrect wasm function name: %s", cf.FunctionName) 65 | } 66 | cf.FunctionName = funcMap[fIndex] 67 | } 68 | 69 | // Creating the function struct 70 | pFn = &profile.Function{ 71 | ID: fnID, 72 | Name: cf.FunctionName, 73 | SystemName: cf.FunctionName, 74 | Filename: cf.URL, 75 | } 76 | fnID++ 77 | // Add it to map 78 | fnMap[fnKey] = pFn 79 | 80 | // Adding it to the function slice 81 | pProf.Function = append(pProf.Function, pFn) 82 | } 83 | 84 | loc := &profile.Location{ 85 | ID: uint64(n.ID), 86 | Line: []profile.Line{ 87 | { 88 | Function: pFn, 89 | Line: cf.LineNumber, 90 | }, 91 | }, 92 | } 93 | 94 | // Add it to map 95 | locMap[n.ID] = locMeta{loc: loc} 96 | 97 | // Add it to location slice 98 | pProf.Location[i] = loc 99 | } 100 | 101 | // Iterate it once more, to build the parent-child chain. 102 | for _, n := range cProf.Nodes { 103 | parent := locMap[n.ID] 104 | for _, childID := range n.Children { 105 | child := locMap[childID] 106 | child.parent = parent.loc 107 | locMap[childID] = child 108 | } 109 | } 110 | 111 | // Final iteration to construct the sample list 112 | pProf.Sample = make([]*profile.Sample, len(cProf.Samples)) 113 | for i, id := range cProf.Samples { 114 | node := locMap[id] 115 | sample := profile.Sample{} 116 | sample.Value = []int64{1, 100000} // XXX: How to get the integer values from ValueType ?? 117 | // walk up the parent chain, and add locations. 118 | leaf := node.loc 119 | parent := node.parent 120 | sample.Location = append(sample.Location, leaf) 121 | for parent != nil { 122 | sample.Location = append(sample.Location, parent) 123 | parent = locMap[int64(parent.ID)].parent 124 | } 125 | // Add it to sample slice 126 | pProf.Sample[i] = &sample 127 | } 128 | 129 | err := pProf.Write(w) 130 | if err != nil { 131 | return err 132 | } 133 | return pProf.CheckValid() 134 | } 135 | -------------------------------------------------------------------------------- /profiler_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "testing" 8 | 9 | "github.com/chromedp/cdproto/profiler" 10 | ) 11 | 12 | func TestWriteProfile(t *testing.T) { 13 | buf, err := os.ReadFile("testdata/wasm.prof") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | var outBuf bytes.Buffer 18 | 19 | cProf := profiler.Profile{} 20 | err = json.Unmarshal(buf, &cProf) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | fnMap := make(map[int]string) 25 | err = WriteProfile(&cProf, &outBuf, fnMap) 26 | if err != nil { 27 | t.Error(err) 28 | } 29 | 30 | golden, err := os.ReadFile("testdata/pprof.out") 31 | if err != nil { 32 | t.Error(err) 33 | } 34 | if !bytes.Equal(outBuf.Bytes(), golden) { 35 | t.Errorf("generated profile is not correct") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /testdata/pprof.out: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agnivade/wasmbrowsertest/eec8f6b3828fd793968ed35171566ab20de7b4cc/testdata/pprof.out -------------------------------------------------------------------------------- /testdata/test.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agnivade/wasmbrowsertest/eec8f6b3828fd793968ed35171566ab20de7b4cc/testdata/test.wasm --------------------------------------------------------------------------------