// Command tester automates the ability to download a suite of JavaScript libraries from a CDN and check if otto can handle them. // // It provides two commands via flags: // * -fetch = Fetch all libraries from the CDN and store them in local testdata directory. // * -report [file1 file2 ... fileN] = Report the results of trying to run the given or if empty all libraries in the testdata directory. package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "regexp" "runtime" "strings" "sync" "text/tabwriter" "time" "github.com/robertkrimen/otto" "github.com/robertkrimen/otto/parser" ) const ( // dataDir is where the libraries are downloaded to. dataDir = "testdata" // downloadWorkers is the number of workers that process downloads. downloadWorkers = 40 // librariesURL is the source for JavaScript libraries for testing. librariesURL = "http://api.cdnjs.com/libraries" // requestTimeout is the maximum time we wait for a request to complete. requestTimeout = time.Second * 20 ) var ( // testWorkers is the number of workers that process report. testWorkers = min(10, runtime.GOMAXPROCS(0)) // noopConsole is a noopConsole which ignore log requests. noopConsole = map[string]interface{}{ "log": func(call otto.FunctionCall) otto.Value { return otto.UndefinedValue() }, } ) var ( matchReferenceErrorNotDefined = regexp.MustCompile(`^ReferenceError: \S+ is not defined$`) matchLookahead = regexp.MustCompile(`Invalid regular expression: re2: Invalid \(\?[=!]\) `) matchBackReference = regexp.MustCompile(`Invalid regular expression: re2: Invalid \\\d `) matchTypeErrorUndefined = regexp.MustCompile(`^TypeError: Cannot access member '[^']+' of undefined$`) ) // broken identifies libraries which fail with a fatal error, so must be skipped. var broken = map[string]string{ "lets-plot.js": "stack overflow", "knockout-es5.js": "stack overflow", "sendbird-calls.js": "runtime: out of memory", } // libraries represents fetch all libraries response. type libraries struct { Results []library `json:"results"` } // library represents a single library in a libraries response. type library struct { Name string `json:"name"` Latest string `json:"latest"` } // fetch fetches itself and stores it in the dataDir. func (l library) fetch() error { if !strings.HasSuffix(l.Latest, ".js") { return nil } ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.Latest, nil) if err != nil { return fmt.Errorf("request library %q: %w", l.Name, err) } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("get library %q: %w", l.Name, err) } defer resp.Body.Close() //nolint:errcheck name := l.Name if !strings.HasSuffix(name, ".js") { name += ".js" } f, err := os.Create(filepath.Join(dataDir, name)) //nolint:gosec if err != nil { return fmt.Errorf("create library %q: %w", l.Name, err) } if _, err = io.Copy(f, resp.Body); err != nil { return fmt.Errorf("write library %q: %w", l.Name, err) } return nil } // test runs the code from filename returning the time it took and any error // encountered when running a full parse without IgnoreRegExpErrors in parseError. func test(filename string) (took time.Duration, parseError, err error) { //nolint:nonamedreturns defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic on %q: %v", filename, r) } }() now := time.Now() defer func() { // Always set took. took = time.Since(now) }() if val := broken[filepath.Base(filename)]; val != "" { return 0, nil, fmt.Errorf("fatal %q", val) } script, err := os.ReadFile(filename) //nolint:gosec if err != nil { return 0, nil, err } vm := otto.New() if err = vm.Set("console", noopConsole); err != nil { return 0, nil, fmt.Errorf("set console: %w", err) } prog, err := parser.ParseFile(nil, filename, string(script), 0) if err != nil { val := err.Error() switch { case matchReferenceErrorNotDefined.MatchString(val), matchTypeErrorUndefined.MatchString(val), matchLookahead.MatchString(val), matchBackReference.MatchString(val): // RegExp issues, retry with IgnoreRegExpErrors. parseError = err if _, err = parser.ParseFile(nil, filename, string(script), parser.IgnoreRegExpErrors); err != nil { return 0, nil, err } return 0, parseError, nil default: return 0, nil, err } } _, err = vm.Run(prog) return 0, nil, err } // fetchAll fetches all files from src. func fetchAll(src string) error { ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, librariesURL, nil) if err != nil { return fmt.Errorf("request libraries %q: %w", src, err) } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("get libraries %q: %w", src, err) } defer resp.Body.Close() //nolint:errcheck var libs libraries dec := json.NewDecoder(resp.Body) if err = dec.Decode(&libs); err != nil { return fmt.Errorf("json decode: %w", err) } if err = os.Mkdir(dataDir, 0o750); err != nil && !errors.Is(err, fs.ErrExist) { return fmt.Errorf("mkdir: %w", err) } var wg sync.WaitGroup work := make(chan library, downloadWorkers) errs := make(chan error, len(libs.Results)) wg.Add(downloadWorkers) for range downloadWorkers { go func() { defer wg.Done() for lib := range work { fmt.Fprint(os.Stdout, ".") errs <- lib.fetch() } }() } fmt.Fprintf(os.Stdout, "Downloading %d libraries with %d workers ...", len(libs.Results), downloadWorkers) wg.Add(1) go func() { defer wg.Done() for _, lib := range libs.Results { work <- lib } close(work) }() wg.Wait() close(errs) fmt.Fprintln(os.Stdout, " done") for e := range errs { if e != nil { fmt.Fprintln(os.Stderr, e) err = e } } return err } // result represents the result from a test. type result struct { err error parseError error filename string took time.Duration } // report runs test for all specified files, if none a specified all // JavaScript files in our dataDir, outputting the results. func report(files []string) error { if len(files) == 0 { var err error files, err = filepath.Glob(filepath.Join(dataDir, "*.js")) if err != nil { return fmt.Errorf("read dir: %w", err) } } var wg sync.WaitGroup workers := min(testWorkers, len(files)) work := make(chan string, workers) results := make(chan result, len(files)) wg.Add(workers) for range workers { go func() { defer wg.Done() for f := range work { fmt.Fprint(os.Stdout, ".") took, parseError, err := test(f) results <- result{ filename: f, err: err, parseError: parseError, took: took, } } }() } fmt.Fprintf(os.Stdout, "Reporting on %d libs with %d workers...", len(files), workers) wg.Add(1) go func() { defer wg.Done() for _, f := range files { work <- f } close(work) }() wg.Wait() close(results) fmt.Fprintln(os.Stdout, " done") var fail, pass, parse int writer := tabwriter.NewWriter(os.Stdout, 0, 8, 0, '\t', 0) fmt.Fprintln(writer, "Library", "\t| Took", "\t| Status") fmt.Fprintln(writer, "-------", "\t| ----", "\t| ------") for res := range results { switch { case res.err != nil: fmt.Fprintf(writer, "%s\t| %v\t| fail: %v\n", res.filename, res.took, res.err) fail++ case res.parseError != nil: fmt.Fprintf(writer, "%s\t| %v\t| pass parse: %v\n", res.filename, res.took, res.parseError) parse++ default: fmt.Fprintf(writer, "%s\t| %v\t| pass\n", res.filename, res.took) pass++ } } if err := writer.Flush(); err != nil { return fmt.Errorf("flush: %w", err) } fmt.Fprintf(os.Stdout, "\nSummary:\n - %d passes\n - %d parse passes\n - %d fails\n", pass, parse, fail) return nil } func main() { flagFetch := flag.Bool("fetch", false, "fetch all libraries for testing") flagReport := flag.Bool("report", false, "test and report the named files or all libraries if non specified") flag.Parse() var err error switch { case *flagFetch: err = fetchAll(librariesURL) case *flagReport: err = report(flag.Args()) default: flag.PrintDefaults() err = errors.New("missing flag") } if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(64) } }