diff --git a/integration/.gitignore b/integration/.gitignore new file mode 100644 index 0000000..151e5e1 --- /dev/null +++ b/integration/.gitignore @@ -0,0 +1 @@ +/integration diff --git a/integration/duckduckgo.js b/integration/duckduckgo.js new file mode 100644 index 0000000..fa268e7 --- /dev/null +++ b/integration/duckduckgo.js @@ -0,0 +1,65 @@ +// Copyright 2023-2025 Lightpanda (Selecy SAS) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use scrict' + +import puppeteer from 'puppeteer-core'; + +const browserAddress = process.env.BROWSER_ADDRESS ? process.env.BROWSER_ADDRESS : 'ws://127.0.0.1:9222'; +const baseURL = process.env.URL ? process.env.URL : 'http://127.0.0.1:1234' + +// use browserWSEndpoint to pass the Lightpanda's CDP server address. +const browser = await puppeteer.connect({ + browserWSEndpoint: browserAddress, +}); + +// The rest of your script remains the same. +const context = await browser.createBrowserContext(); +const page = await context.newPage(); + +await page.goto('https://duckduckgo.com', {waitUtil: 'networkidle0'}); + +await page.type('#searchbox_input','lightpanda'), +await Promise.all([ + page.waitForNavigation(), + page.keyboard.press('Enter'), +]); + +await page.waitForNetworkIdle({timeout: 4000}); + +const links = await page.evaluate(() => { + return Array.from(document.querySelectorAll('a[data-testid="result-title-a"]')).map(row => { + return row.getAttribute('href'); + }); +}); + +await page.close(); +await context.close(); +await browser.disconnect(); + +let found = { + homepage: false, + github: false, + docs: false, +} +for (const link of links) { + if (link === 'https://lightpanda.io/') found.homepage = true; + else if (link === 'https://github.com/lightpanda-io/browser') found.github = true; + else if (link.startsWith('https://lightpanda.io/docs/')) found.docs = true; +} + +if (!found.homepage || !found.github || !found.docs) { + console.log("Failed to find expected links", found); + throw new Error("invalid results"); +} + diff --git a/integration/go.mod b/integration/go.mod new file mode 100644 index 0000000..eaef13a --- /dev/null +++ b/integration/go.mod @@ -0,0 +1,3 @@ +module github.com/lightpanda-io/demo/integration + +go 1.23 diff --git a/integration/main.go b/integration/main.go new file mode 100644 index 0000000..34fc784 --- /dev/null +++ b/integration/main.go @@ -0,0 +1,219 @@ +// Copyright 2023-2025 Lightpanda (Selecy SAS) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "os/exec" + "strings" + "time" +) + +const ( + exitOK = 0 + exitFail = 1 +) + +// main starts interruptable context and runs the program. +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := run(ctx, os.Args, os.Stdout, os.Stderr) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(exitFail) + } + + os.Exit(exitOK) +} + +const ( + httpAddrDefault = "127.0.0.1:1234" + httpDirDefault = "public" +) + +func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { + // declare runtime flag parameters. + flags := flag.NewFlagSet(args[0], flag.ExitOnError) + flags.SetOutput(stderr) + + var ( + verbose = flags.Bool("verbose", false, "enable debug log level") + httpAddr = flags.String("http-addr", env("RUNNER_HTTP_ADDRESS", httpAddrDefault), "http server address") + httpDir = flags.String("http-dir", env("RUNNER_HTTP_DIR", httpDirDefault), "http dir to expose") + ) + + // usage func declaration. + bin := args[0] + flags.Usage = func() { + fmt.Fprintf(stderr, "usage: %s\n", bin) + fmt.Fprintf(stderr, "end to end tests\n") + fmt.Fprintf(stderr, "\nCommand line options:\n") + flags.PrintDefaults() + fmt.Fprintf(stderr, "\nEnvironment vars:\n") + fmt.Fprintf(stderr, "\tRUNNER_HTTP_ADDRESS\tdefault %s\n", httpAddrDefault) + fmt.Fprintf(stderr, "\tRUNNER_HTTP_DIR\tdefault %s\n", httpDirDefault) + } + if err := flags.Parse(args[1:]); err != nil { + return err + } + + if *verbose { + slog.SetLogLoggerLevel(slog.LevelDebug) + } + + args = flags.Args() + if len(args) != 0 { + return errors.New("too many arguments") + } + + // Start the http server in its own goroutine. + go func() { + if err := runhttp(ctx, *httpAddr, *httpDir); err != nil { + slog.Error("http server", slog.String("err", err.Error())) + } + }() + + // Run end to end tests. + fails := 0 + for _, t := range []Test{ + {Bin: "node", Args: []string{"integration/duckduckgo.js"}}, + } { + if *verbose { + t.Stderr = stderr + t.Stdout = stdout + fmt.Fprintf(stdout, "=== \t%s\n", t) + } + + start := time.Now() + if err := runtest(ctx, t); err != nil { + fmt.Fprintf(stdout, "=== ERR\t%s\n", t) + fails++ + continue + } + + fmt.Fprintf(stdout, "=== OK\t%v\t%s\n", time.Since(start), t) + } + + if fails > 0 { + return fmt.Errorf("%d failures", fails) + } + return nil +} + +type Test struct { + Bin string + Args []string + Env []string // key=value + Dir string + Stdout io.Writer + Stderr io.Writer +} + +func (t Test) String() string { + return t.Bin + " " + strings.Join(t.Args, " ") +} + +func runtest(ctx context.Context, t Test) error { + cmd := exec.CommandContext(ctx, t.Bin, t.Args...) + + cmd.Env = t.Env + cmd.Dir = t.Dir + cmd.Stdout = t.Stdout + cmd.Stderr = t.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("run: %w", err) + } + + return nil +} + +// run the local http server +func runhttp(ctx context.Context, addr, dir string) error { + fs := http.FileServer(http.Dir(dir)) + + srv := &http.Server{ + Addr: addr, + Handler: Handler{fs: fs}, + BaseContext: func(net.Listener) context.Context { + return ctx + }, + } + + // shutdown api server on context cancelation + go func(ctx context.Context, srv *http.Server) { + <-ctx.Done() + slog.Debug("http server shutting down") + // we use context.Background() here b/c ctx is already canceled. + if err := srv.Shutdown(context.Background()); err != nil { + // context cancellation error is ignored. + if !errors.Is(err, context.Canceled) { + slog.Error("http server shutdown", slog.String("err", err.Error())) + } + } + }(ctx, srv) + + // ListenAndServe always returns a non-nil error. + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + return fmt.Errorf("http server: %w", err) + } + + return nil +} + +// env returns the env value corresponding to the key or the default string. +func env(key, dflt string) string { + val, ok := os.LookupEnv(key) + if !ok { + return dflt + } + + return val +} + +type Handler struct { + fs http.Handler +} + +func (h Handler) ServeHTTP(res http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/form/submit": + defer req.Body.Close() + body, err := io.ReadAll(req.Body) + if err != nil { + panic(err) + } + + res.Header().Add("Content-Type", "text/html") + res.Write([]byte("