diff --git a/bin/ChromiumVersion.ml b/bin/ChromiumVersion.ml new file mode 100644 index 0000000..b3e1c94 --- /dev/null +++ b/bin/ChromiumVersion.ml @@ -0,0 +1,107 @@ +open Cohttp_lwt_unix +open Lwt.Syntax + +type platform = + | Win64 + | MacOS + | MacOS_ARM + | Linux +;; + +Fmt.set_style_renderer Fmt.stdout `Ansi_tty + +let print_green msg = + let printer = Fmt.pr " %a " (Fmt.styled `Green Fmt.string) in + printer msg +;; + +let print_red msg = + let printer = Fmt.pr " %a " (Fmt.styled `Red Fmt.string) in + printer msg +;; + +let check_availability revision platform = + let uri = + Uri.make + ~scheme:"http" + ~host:"storage.googleapis.com" + ~port:80 + ~path: + (match platform with + | MacOS -> "/chromium-browser-snapshots/Mac/" ^ revision ^ "/chrome-mac.zip" + | MacOS_ARM -> + "/chromium-browser-snapshots/Mac_Arm/" ^ revision ^ "/chrome-mac.zip" + | Linux -> + "/chromium-browser-snapshots/Linux_x64/" ^ revision ^ "/chrome-linux.zip" + | Win64 -> "/chromium-browser-snapshots/Win_x64/" ^ revision ^ "/chrome-win.zip") + () + in + let* response = Client.head uri in + match response with + | { status = `OK; _ } -> Lwt_result.return platform + | _ -> Lwt_result.fail platform +;; + +let platform_to_string = function + | Win64 -> "win64" + | MacOS -> "mac" + | MacOS_ARM -> "mac_arm" + | Linux -> "linux" +;; + +let rec check_revision revision = + let revision_str = string_of_int revision in + let* results = + Lwt.all + [ check_availability revision_str Linux + ; check_availability revision_str MacOS + ; check_availability revision_str MacOS_ARM + ; check_availability revision_str Win64 + ] + in + flush stdout; + print_int revision; + print_string ":"; + let _available, not_available = + results + |> List.partition_map (function + | Result.Ok platform -> + print_green (platform_to_string platform); + Either.left platform + | Result.Error platform -> + print_red (platform_to_string platform); + Either.right platform) + in + flush stdout; + print_endline ""; + match not_available with + | [] -> Lwt.return revision + | _ -> check_revision (pred revision) +;; + +Lwt_main.run + (let uri = Uri.make ~scheme:"http" ~host:"storage.googleapis.com" ~port:80 in + let* latestRevisions = + Lwt.all + [ Client.get (uri ~path:"/chromium-browser-snapshots/Mac/LAST_CHANGE" ()) + ; Client.get (uri ~path:"/chromium-browser-snapshots/Mac_Arm/LAST_CHANGE" ()) + ; Client.get (uri ~path:"/chromium-browser-snapshots/Linux_x64/LAST_CHANGE" ()) + ; Client.get (uri ~path:"/chromium-browser-snapshots/Win_x64/LAST_CHANGE" ()) + ] + in + let* latestRevisions = + latestRevisions + |> Lwt_list.map_p (function + | { Response.status = `OK; _ }, body -> Cohttp_lwt.Body.to_string body + | response, _body -> + Format.fprintf + Format.err_formatter + "Latest version could not be checked:\n%a\n%!" + Response.pp_hum + response; + exit 1) + in + latestRevisions + |> List.map int_of_string + |> List.fold_left min Int.max_int + |> check_revision) diff --git a/bin/ChromiumVersion.re b/bin/ChromiumVersion.re deleted file mode 100644 index 890152e..0000000 --- a/bin/ChromiumVersion.re +++ /dev/null @@ -1,137 +0,0 @@ -open Cohttp_lwt_unix; -open Lwt.Syntax; - -type platform = - | Win64 - | MacOS - | MacOS_ARM - | Linux; - -Fmt.set_style_renderer(Fmt.stdout, `Ansi_tty); - -let print_green = msg => { - let printer = Fmt.pr(" %a ", Fmt.styled(`Green, Fmt.string)); - printer(msg); -}; - -let print_red = msg => { - let printer = Fmt.pr(" %a ", Fmt.styled(`Red, Fmt.string)); - printer(msg); -}; - -let check_availability = (revision, platform) => { - let uri = Uri.make( - ~scheme="http", - ~host="storage.googleapis.com", - ~port=80, - ~path= - switch (platform) { - | MacOS => - "/chromium-browser-snapshots/Mac/" ++ revision ++ "/chrome-mac.zip" - | MacOS_ARM => - "/chromium-browser-snapshots/Mac_Arm/" ++ revision ++ "/chrome-mac.zip" - | Linux => - "/chromium-browser-snapshots/Linux_x64/" - ++ revision - ++ "/chrome-linux.zip" - | Win64 => - "/chromium-browser-snapshots/Win_x64/" ++ revision ++ "/chrome-win.zip" - }, - (), - ); - - let* response = Client.head(uri); - - switch (response) { - | {status: `OK, _} => Lwt_result.return(platform) - | _ => Lwt_result.fail(platform) - }; -}; - -let platform_to_string = - fun - | Win64 => "win64" - | MacOS => "mac" - | MacOS_ARM => "mac_arm" - | Linux => "linux"; - -let rec check_revision = revision => { - let revision_str = string_of_int(revision); - let* results = - Lwt.all([ - check_availability(revision_str, Linux), - check_availability(revision_str, MacOS), - check_availability(revision_str, MacOS_ARM), - check_availability(revision_str, Win64), - ]); - - flush(stdout); - print_int(revision); - print_string(":"); - - let (_available, not_available) = - results - |> List.partition_map( - fun - | Result.Ok(platform) => { - print_green(platform_to_string(platform)); - Either.left(platform); - } - | Result.Error(platform) => { - print_red(platform_to_string(platform)); - Either.right(platform); - }, - ); - - flush(stdout); - print_endline(""); - - switch (not_available) { - | [] => Lwt.return(revision) - | _ => check_revision(pred(revision)) - }; -}; - -Lwt_main.run( - { - let uri = - Uri.make(~scheme="http", ~host="storage.googleapis.com", ~port=80); - - let* latestRevisions = - Lwt.all([ - Client.get( - uri(~path="/chromium-browser-snapshots/Mac/LAST_CHANGE", ()), - ), - Client.get( - uri(~path="/chromium-browser-snapshots/Mac_Arm/LAST_CHANGE", ()), - ), - Client.get( - uri(~path="/chromium-browser-snapshots/Linux_x64/LAST_CHANGE", ()), - ), - Client.get( - uri(~path="/chromium-browser-snapshots/Win_x64/LAST_CHANGE", ()), - ), - ]); - let* latestRevisions = - latestRevisions - |> Lwt_list.map_p( - fun - | ({Response.status: `OK, _}, body) => - Cohttp_lwt.Body.to_string(body) - | (response, _body) => { - Format.fprintf( - Format.err_formatter, - "Latest version could not be checked:\n%a\n%!", - Response.pp_hum, - response, - ); - exit(1); - }, - ); - - latestRevisions - |> List.map(int_of_string) - |> List.fold_left(min, Int.max_int) - |> check_revision; - }, -); diff --git a/bin/Main.ml b/bin/Main.ml new file mode 100644 index 0000000..9586ac2 --- /dev/null +++ b/bin/Main.ml @@ -0,0 +1,221 @@ +open Cmdliner;; + +Printexc.record_backtrace true;; +Fmt.set_style_renderer Fmt.stdout `Ansi_tty + +let setup_log = + let open Term in + const OSnap.Logger.init $ Fmt_cli.style_renderer () $ Logs_cli.level () +;; + +let print_error msg = + let printer = Fmt.pr "%a @." (Fmt.styled `Red Fmt.string) in + Printf.ksprintf printer msg +;; + +let handle_response response = + match response with + | Ok () -> 0 + | Error OSnap_Response.Test_Failure -> 1 + | Error (OSnap_Response.Config_Duplicate_Tests tests) -> + print_error + "Found some tests with duplicate names. Every test has to have a unique name."; + print_error "Please rename the following tests: \n"; + tests |> List.iter (print_error "%s"); + 1 + | Error OSnap_Response.Config_Global_Not_Found -> + print_error "Unable to find a global config file."; + print_error + "Please create a \"osnap.config.json\" at the root of your project or specifiy the \ + location using the --config option."; + 1 + | Error (OSnap_Response.Config_Unsupported_Format path) -> + print_error "Your config file has an unknown format."; + print_error "Tried to parse %S." path; + print_error "Known formats are json and yaml"; + 1 + | Error OSnap_Response.CDP_Connection_Failed -> + print_error "Could not connect to Chrome."; + 1 + | Error (OSnap_Response.Config_Parse_Error (msg, path)) -> + print_error "Your config is in an invalid format"; + (match path with + | Some path -> print_error "Tried to parse %S" path + | None -> ()); + print_error "%s" msg; + 1 + | Error (OSnap_Response.Config_Invalid (s, path)) -> + print_error "Found some tests with an invalid format."; + (match path with + | Some path -> print_error "Tried to parse %S" path + | None -> ()); + print_error "%s" s; + 1 + | Error (OSnap_Response.Config_Duplicate_Size_Names sizes) -> + print_error + "Found some sizes with duplicate names. Every size has to have a unique name \ + inside it's list."; + print_error "Please rename the following sizes: \n"; + sizes |> List.iter (print_error "%s"); + 1 + | Error (OSnap_Response.CDP_Protocol_Error e) -> + print_error "CDP failed to run some commands. Message was: \n"; + print_error "%s" e; + 1 + | Error (OSnap_Response.Invalid_Run msg) -> + print_error "%s" msg; + 1 + | Error (OSnap_Response.FS_Error _) -> 1 + | Error (OSnap_Response.Unknown_Error exn) -> + print_error "An unexpected error occured: \n"; + print_error "%s" (Printexc.to_string exn); + 1 +;; + +let config = + let doc = "The relative path to the global config file." in + let default = "" in + let open Arg in + value & opt file default & info [ "config" ] ~doc +;; + +let default_cmd = + let noCreate = + let doc = + "With this option enabled, new snapshots will not be created, but fail the whole \ + test run instead. This option is recommended for ci environments." + in + let open Arg in + value & flag & info [ "no-create" ] ~doc + in + let noOnly = + let doc = + "With this option enabled, the test run will fail, if you have any test with \ + \"only\" set to true. This option is recommended for ci environments." + in + let open Arg in + value & flag & info [ "no-only" ] ~doc + in + let noSkip = + let doc = + "With this option enabled, the test run will fail, if you have any test with \ + \"skip\" set to true. This option is recommended for ci environments." + in + let open Arg in + value & flag & info [ "no-skip" ] ~doc + in + let parallelism = + let doc = + "Overwrite the parallelism defined in the global config file with the specified \ + value." + in + let open Arg in + value & opt (some int) None & info [ "p"; "parallelism" ] ~doc + in + let exec noCreate noOnly noSkip parallelism config_path () = + let open Lwt_result.Syntax in + let run = + let* t = OSnap.setup ~config_path ~noCreate ~noOnly ~noSkip ~parallelism in + [%lwt + try + OSnap.run t + |> Lwt_result.map_error (fun e -> + let () = OSnap.teardown t in + e) + with + | exn -> + let () = OSnap.teardown t in + Lwt_result.fail (OSnap_Response.Unknown_Error exn)] + in + Lwt_main.run run |> handle_response + in + ( (let open Term in + const exec $ noCreate $ noOnly $ noSkip $ parallelism $ config $ setup_log) + , Cmd.info + "osnap" + ~man: + [ `S Manpage.s_description + ; `P + "OSnap is a snapshot testing tool, which uses chrome to take screenshots and \ + compares them with a base image taken previously." + ; `P "If both images are equal, the test passes." + ; `P + "If the images aren't equal, the test fails and puts the new image into the \ + \"__updated__\" folder inside of your snapshot folder. It also generates a \ + new image, which shows the base image (how it looked before), an image with \ + the differing pixels highlighted and the new image side by side." + ; `P + "There is no \"update\" command to update the snapshots. If the changes \ + shown in the diff image are expected, you just have to move and replace the \ + image from the \"__updated__\" folder into the \"__base_images__\" folder." + ] + ~exits: + (let open Cmd.Exit in + [ info 0 ~doc:"on success" + ; info 1 ~doc:"on failed test runs" + ; info 124 ~doc:"on command line parsing errors." + ; info 125 ~doc:"on unexpected internal errors." + ]) ) +;; + +let cleanup_cmd = + let exec config_path = OSnap.cleanup ~config_path |> handle_response in + ( (let open Term in + const exec $ config) + , Cmd.info + "cleanup" + ~man: + [ `S Manpage.s_description + ; `P + "The cleanup command removes all unused base images from the snapshot \ + folder. This may happen, when a test is removed or renamed." + ] + ~exits: + (let open Cmd.Exit in + [ info 0 ~doc:"on success" + ; info 124 ~doc:"on command line parsing errors." + ; info 125 ~doc:"on unexpected internal errors." + ]) ) +;; + +let download_chromium_cmd = + let exec () = + let run = + [%lwt + try OSnap.download_chromium () with + | Failure message -> + print_error "%s" message; + Lwt_result.fail () + | exn -> raise exn] + in + match Lwt_main.run run with + | Ok () -> 0 + | Error () -> 1 + in + ( (let open Term in + const exec $ const ()) + , Cmd.info + "download-chromium" + ~man: + [ `S Manpage.s_description + ; `P + "The download-chromium command downloads the latest compatible version of \ + chromium." + ] + ~exits: + (let open Cmd.Exit in + [ info 0 ~doc:"on success" + ; info 124 ~doc:"on command line parsing errors." + ; info 125 ~doc:"on unexpected internal errors." + ]) ) +;; + +let cmds = + [ Cmd.v (snd cleanup_cmd) (fst cleanup_cmd) + ; Cmd.v (snd download_chromium_cmd) (fst download_chromium_cmd) + ] +;; + +let default, info = default_cmd;; + +Cmd.eval' (Cmd.group ~default info cmds) diff --git a/bin/Main.re b/bin/Main.re deleted file mode 100644 index f90c09d..0000000 --- a/bin/Main.re +++ /dev/null @@ -1,255 +0,0 @@ -open Cmdliner; - -Printexc.record_backtrace(true); -Fmt.set_style_renderer(Fmt.stdout, `Ansi_tty); - -let setup_log = { - Term.( - const(OSnap.Logger.init) $ Fmt_cli.style_renderer() $ Logs_cli.level() - ); -}; - -let print_error = msg => { - let printer = Fmt.pr("%a @.", Fmt.styled(`Red, Fmt.string)); - Printf.ksprintf(printer, msg); -}; - -let handle_response = response => { - switch (response) { - | Ok () => 0 - | Error(OSnap_Response.Test_Failure) => 1 - | Error(OSnap_Response.Config_Duplicate_Tests(tests)) => - print_error( - "Found some tests with duplicate names. Every test has to have a unique name.", - ); - print_error("Please rename the following tests: \n"); - tests |> List.iter(print_error("%s")); - 1; - | Error(OSnap_Response.Config_Global_Not_Found) => - print_error("Unable to find a global config file."); - print_error( - "Please create a \"osnap.config.json\" at the root of your project or specifiy the location using the --config option.", - ); - 1; - | Error(OSnap_Response.Config_Unsupported_Format(path)) => - print_error("Your config file has an unknown format."); - print_error("Tried to parse %S.", path); - print_error("Known formats are json and yaml"); - 1; - | Error(OSnap_Response.CDP_Connection_Failed) => - print_error("Could not connect to Chrome."); - 1; - | Error(OSnap_Response.Config_Parse_Error(msg, path)) => - print_error("Your config is in an invalid format"); - switch (path) { - | Some(path) => print_error("Tried to parse %S", path) - | None => () - }; - print_error("%s", msg); - 1; - | Error(OSnap_Response.Config_Invalid(s, path)) => - print_error("Found some tests with an invalid format."); - switch (path) { - | Some(path) => print_error("Tried to parse %S", path) - | None => () - }; - print_error("%s", s); - 1; - | Error(OSnap_Response.Config_Duplicate_Size_Names(sizes)) => - print_error( - "Found some sizes with duplicate names. Every size has to have a unique name inside it's list.", - ); - print_error("Please rename the following sizes: \n"); - sizes |> List.iter(print_error("%s")); - 1; - | Error(OSnap_Response.CDP_Protocol_Error(e)) => - print_error("CDP failed to run some commands. Message was: \n"); - print_error("%s", e); - 1; - | Error(OSnap_Response.Invalid_Run(msg)) => - print_error("%s", msg); - 1; - | Error(OSnap_Response.FS_Error(_)) => 1 - | Error(OSnap_Response.Unknown_Error(exn)) => - print_error("An unexpected error occured: \n"); - print_error("%s", Printexc.to_string(exn)); - 1; - }; -}; - -let config = { - let doc = " - The relative path to the global config file. - "; - let default = ""; - Arg.(value & opt(file, default) & info(["config"], ~doc)); -}; - -let default_cmd = { - let noCreate = { - let doc = " - With this option enabled, new snapshots will not be created, but fail the whole test run instead. - This option is recommended for ci environments. - "; - Arg.(value & flag & info(["no-create"], ~doc)); - }; - - let noOnly = { - let doc = " - With this option enabled, the test run will fail, if you have any test with \"only\" set to true. - This option is recommended for ci environments. - "; - Arg.(value & flag & info(["no-only"], ~doc)); - }; - - let noSkip = { - let doc = " - With this option enabled, the test run will fail, if you have any test with \"skip\" set to true. - This option is recommended for ci environments. - "; - Arg.(value & flag & info(["no-skip"], ~doc)); - }; - - let parallelism = { - let doc = " - Overwrite the parallelism defined in the global config file with the specified value. - "; - Arg.(value & opt(some(int), None) & info(["p", "parallelism"], ~doc)); - }; - - let exec = (noCreate, noOnly, noSkip, parallelism, config_path, ()) => { - open Lwt_result.Syntax; - - let run = { - let* t = - OSnap.setup(~config_path, ~noCreate, ~noOnly, ~noSkip, ~parallelism); - - try%lwt( - OSnap.run(t) - |> Lwt_result.map_error(e => { - let () = OSnap.teardown(t); - e; - }) - ) { - | exn => - let () = OSnap.teardown(t); - Lwt_result.fail(OSnap_Response.Unknown_Error(exn)); - }; - }; - - Lwt_main.run(run) |> handle_response; - }; - - ( - Term.( - const(exec) - $ noCreate - $ noOnly - $ noSkip - $ parallelism - $ config - $ setup_log - ), - Cmd.info( - "osnap", - ~man=[ - `S(Manpage.s_description), - `P( - "OSnap is a snapshot testing tool, which uses chrome to take screenshots and compares them with a base image taken previously.", - ), - `P("If both images are equal, the test passes."), - `P( - "If the images aren't equal, the test fails and puts the new image into the \"__updated__\" folder inside of your snapshot folder. - It also generates a new image, which shows the base image (how it looked before), an image with the differing pixels - highlighted and the new image side by side.", - ), - `P( - "There is no \"update\" command to update the snapshots. If the changes shown in the diff image are expected, - you just have to move and replace the image from the \"__updated__\" folder into the \"__base_images__\" folder.", - ), - ], - ~exits= - Cmd.Exit.[ - info(0, ~doc="on success"), - info(1, ~doc="on failed test runs"), - info(124, ~doc="on command line parsing errors."), - info(125, ~doc="on unexpected internal errors."), - ], - ), - ); -}; - -let cleanup_cmd = { - let exec = config_path => { - OSnap.cleanup(~config_path) |> handle_response; - }; - - ( - Term.(const(exec) $ config), - Cmd.info( - "cleanup", - ~man=[ - `S(Manpage.s_description), - `P( - " - The cleanup command removes all unused base images from the snapshot folder. - This may happen, when a test is removed or renamed. - ", - ), - ], - ~exits= - Cmd.Exit.[ - info(0, ~doc="on success"), - info(124, ~doc="on command line parsing errors."), - info(125, ~doc="on unexpected internal errors."), - ], - ), - ); -}; - -let download_chromium_cmd = { - let exec = () => { - let run = { - try%lwt(OSnap.download_chromium()) { - | Failure(message) => - print_error("%s", message); - Lwt_result.fail(); - | exn => raise(exn) - }; - }; - - switch (Lwt_main.run(run)) { - | Ok () => 0 - | Error () => 1 - }; - }; - - ( - Term.(const(exec) $ const()), - Cmd.info( - "download-chromium", - ~man=[ - `S(Manpage.s_description), - `P( - " - The download-chromium command downloads the latest compatible version of chromium. - ", - ), - ], - ~exits= - Cmd.Exit.[ - info(0, ~doc="on success"), - info(124, ~doc="on command line parsing errors."), - info(125, ~doc="on unexpected internal errors."), - ], - ), - ); -}; - -let cmds = [ - Cmd.v(snd(cleanup_cmd), fst(cleanup_cmd)), - Cmd.v(snd(download_chromium_cmd), fst(download_chromium_cmd)), -]; - -let (default, info) = default_cmd; -Cmd.eval'(Cmd.group(~default, info, cmds)); diff --git a/lib/OSnap.ml b/lib/OSnap.ml new file mode 100644 index 0000000..4344595 --- /dev/null +++ b/lib/OSnap.ml @@ -0,0 +1,252 @@ +module Config = OSnap_Config +module Browser = OSnap_Browser +module Printer = OSnap_Printer +module Logger = OSnap_Logger +module Utils = OSnap_Utils + +module Lwt_list = struct + include Lwt_list + + let map_p_until_exception fn list = + let open! Lwt.Syntax in + let rec loop acc list = + match list with + | [] -> Lwt_result.return acc + | list -> + let* resolved, pending = Lwt.nchoose_split list in + let success, error = + resolved + |> List.partition_map (function + | Ok v -> Either.left v + | Error e -> Either.right e) + in + (match error with + | [] -> loop (success @ acc) pending + | hd :: _tl -> + pending |> List.iter Lwt.cancel; + Lwt_result.fail hd) + in + let promises = list |> List.map (Lwt.apply fn) in + loop [] promises + ;; +end + +type t = + { config : Config.Types.global + ; all_tests : (Config.Types.test * Config.Types.size * bool) list + ; tests_to_run : (Config.Types.test * Config.Types.size * bool) list + ; start_time : float + ; browser : Browser.t + } + +let init_folder_structure config = + let debug = Logger.debug ~header:"SETUP" in + let dirs = OSnap_Paths.get config in + if not (Sys.file_exists dirs.base) + then ( + debug ("creating base images folder at " ^ dirs.base); + FileUtil.mkdir ~parent:true ~mode:(`Octal 0o755) dirs.base); + debug ("(re)creating " ^ dirs.updated); + FileUtil.rm ~recurse:true [ dirs.updated ]; + FileUtil.mkdir ~parent:true ~mode:(`Octal 0o755) dirs.updated; + debug ("(re)creating " ^ dirs.diff); + FileUtil.rm ~recurse:true [ dirs.diff ]; + FileUtil.mkdir ~parent:true ~mode:(`Octal 0o755) dirs.diff +;; + +let setup ~noCreate ~noOnly ~noSkip ~parallelism ~config_path = + let open Config.Types in + let open Lwt_result.Syntax in + let debug = Logger.debug ~header:"SETUP" in + let start_time = Unix.gettimeofday () in + let* config = Config.Global.init ~config_path |> Lwt_result.lift in + let config = + match parallelism with + | Some parallelism -> { config with parallelism } + | None -> config + in + let () = init_folder_structure config in + let snapshot_dir = OSnap_Paths.get_base_images_dir config in + debug "looking for test files"; + let* tests = Config.Test.init config |> Lwt_result.lift in + debug (Printf.sprintf "found %i test files" (List.length tests)); + debug "collecting test sizes to run"; + let* all_tests = + tests + |> Lwt_list.map_p_until_exception (fun test -> + test.sizes + |> Lwt_list.map_p_until_exception (fun size -> + let { name = _size_name; width; height } = size in + let filename = OSnap_Test.get_filename test.name width height in + let current_image_path = snapshot_dir ^ filename in + let exists = Sys.file_exists current_image_path in + if noCreate && not exists + then + Lwt_result.fail + (OSnap_Response.Invalid_Run + (Printf.sprintf + "Flag --no-create is set. Cannot create new images for %s." + test.name)) + else Lwt_result.return (test, size, exists))) + |> Lwt_result.map List.flatten + in + debug "checking for \"only\" flags"; + let only_tests = all_tests |> List.find_all (fun (test, _, _) -> test.only) in + let* tests_to_run = + if noOnly && List.length only_tests > 0 + then + Lwt_result.fail + (OSnap_Response.Invalid_Run + (only_tests + |> List.map (fun ((test : Config.Types.test), _, _) -> test.name) + |> List.sort_uniq String.compare + |> String.concat ",\n" + |> Printf.sprintf + "Flag --no-only is set, but the following tests still have only set to \ + true:\n\ + %s")) + else if List.length only_tests > 0 + then Lwt_result.return only_tests + else Lwt_result.return all_tests + in + debug "checking for \"skip\" flags"; + let skipped_tests, tests_to_run = + tests_to_run |> List.partition (fun (test, _, _) -> test.skip) + in + let* tests_to_run = + if noSkip && List.length skipped_tests > 0 + then + Lwt_result.fail + (OSnap_Response.Invalid_Run + (skipped_tests + |> List.map (fun ((test : Config.Types.test), _, _) -> test.name) + |> List.sort_uniq String.compare + |> String.concat ",\n" + |> Printf.sprintf + "Flag --no-skip is set, but the following tests still have \"skip\" set \ + to true:\n\ + %s")) + else if List.length skipped_tests > 0 + then ( + skipped_tests + |> List.iter (fun ((test : Config.Types.test), { width; height; _ }, _) -> + Printer.skipped_message ~name:test.name ~width ~height); + Lwt_result.return tests_to_run) + else Lwt_result.return tests_to_run + in + debug "setting test priority"; + let tests_to_run = + tests_to_run + |> List.fast_sort (fun (_test, _size, exists1) (_test, _size, exists2) -> + Bool.compare exists1 exists2) + in + debug "launching browser"; + let* browser = Browser.Launcher.make () in + Lwt_result.return { config; all_tests; tests_to_run; start_time; browser } +;; + +let teardown t = Browser.Launcher.shutdown t.browser + +let run t = + let open Config.Types in + let open Lwt_result.Syntax in + let debug = Logger.debug ~header:"RUN" in + let { tests_to_run; all_tests; config; start_time; browser } = t in + let parallelism = max 1 config.parallelism in + debug (Printf.sprintf "creating pool of %i runners" parallelism); + let pool = + Lwt_pool.create + parallelism + (fun () -> Browser.Target.make browser) + ~validate:(fun target -> Lwt.return (Result.is_ok target)) + in + let* test_results = + tests_to_run + |> Lwt_list.map_p_until_exception (fun test -> + Lwt_pool.use pool (fun target -> + let test, { name = size_name; width; height }, exists = test in + let test = + ({ exists + ; size_name + ; width + ; height + ; url = test.url + ; name = test.name + ; actions = test.actions + ; ignore_regions = test.ignore + ; threshold = test.threshold + } + : OSnap_Test.t) + in + OSnap_Test.run config (Result.get_ok target) test)) + in + let end_time = Unix.gettimeofday () in + let seconds = end_time -. start_time in + Browser.Launcher.shutdown browser; + let create_count = test_results |> List.filter (fun r -> r = `Created) |> List.length in + let passed_count = test_results |> List.filter (fun r -> r = `Passed) |> List.length in + let failed_tests = + test_results + |> List.filter_map (function + | `Passed | `Created -> None + | `Failed _ as r -> Some r) + in + let test_count = tests_to_run |> List.length in + Printer.stats + ~test_count + ~create_count + ~passed_count + ~failed_tests + ~skipped_count:(List.length all_tests - test_count) + ~seconds; + match failed_tests with + | [] -> Lwt_result.return () + | _ -> Lwt_result.fail OSnap_Response.Test_Failure +;; + +let cleanup ~config_path = + let ( let* ) = Result.bind in + print_newline (); + let* config = Config.Global.init ~config_path in + let () = init_folder_structure config in + let snapshot_dir = OSnap_Paths.get_base_images_dir config in + let* tests = Config.Test.init config in + let test_file_paths = + tests + |> List.map (fun (test : Config.Types.test) -> + test.sizes + |> List.filter_map (fun (size : Config.Types.size) -> + let Config.Types.{ width; height; _ } = size in + let filename = OSnap_Test.get_filename test.name width height in + let current_image_path = snapshot_dir ^ filename in + let exists = Sys.file_exists current_image_path in + if exists then Some current_image_path else None)) + |> List.flatten + in + let files_to_delete = + FileUtil.ls snapshot_dir + |> List.find_all (fun file -> not (List.mem file test_file_paths)) + in + let num_files_to_delete = List.length files_to_delete in + let open Fmt in + if num_files_to_delete > 0 + then ( + Fmt.pr + "%a @." + (styled `Bold string) + (Printf.sprintf "Deleting %i files...\n" num_files_to_delete); + files_to_delete + |> List.iter (fun file -> + FileUtil.rm [ file ]; + Fmt.pr "%a @." (styled `Faint string) (Printf.sprintf "Deleted %s" file)); + Fmt.pr "\n%a @." (styled `Bold (styled `Green string)) "Done!") + else + Fmt.pr + "%a @." + (styled `Bold (styled `Green string)) + "Everything clean. No files to remove!"; + print_newline (); + Result.ok () +;; + +let download_chromium = OSnap_Browser.Download.download \ No newline at end of file diff --git a/lib/OSnap.mli b/lib/OSnap.mli new file mode 100644 index 0000000..2fcc6e1 --- /dev/null +++ b/lib/OSnap.mli @@ -0,0 +1,17 @@ +module Logger = OSnap_Logger +module Utils = OSnap_Utils + +type t + +val setup + : noCreate:bool + -> noOnly:bool + -> noSkip:bool + -> parallelism:int option + -> config_path:string + -> (t, OSnap_Response.t) Lwt_result.t + +val teardown : t -> unit +val cleanup : config_path:string -> (unit, OSnap_Response.t) Result.t +val run : t -> (unit, OSnap_Response.t) Lwt_result.t +val download_chromium : unit -> (unit, unit) Lwt_result.t \ No newline at end of file diff --git a/lib/OSnap.re b/lib/OSnap.re deleted file mode 100644 index 25b9b45..0000000 --- a/lib/OSnap.re +++ /dev/null @@ -1,324 +0,0 @@ -module Config = OSnap_Config; -module Browser = OSnap_Browser; - -module Printer = OSnap_Printer; -module Logger = OSnap_Logger; - -module Utils = OSnap_Utils; - -module Lwt_list = { - include Lwt_list; - - let map_p_until_exception = (fn, list) => { - open! Lwt.Syntax; - let rec loop = (acc, list) => { - switch (list) { - | [] => Lwt_result.return(acc) - | list => - let* (resolved, pending) = Lwt.nchoose_split(list); - let (success, error) = - resolved - |> List.partition_map( - fun - | Ok(v) => Either.left(v) - | Error(e) => Either.right(e), - ); - - switch (error) { - | [] => loop(success @ acc, pending) - | [hd, ..._tl] => - pending |> List.iter(Lwt.cancel); - Lwt_result.fail(hd); - }; - }; - }; - - let promises = list |> List.map(Lwt.apply(fn)); - loop([], promises); - }; -}; - -type t = { - config: Config.Types.global, - all_tests: list((Config.Types.test, Config.Types.size, bool)), - tests_to_run: list((Config.Types.test, Config.Types.size, bool)), - start_time: float, - browser: Browser.t, -}; - -let init_folder_structure = config => { - let debug = Logger.debug(~header="SETUP"); - - let dirs = OSnap_Paths.get(config); - - if (!Sys.file_exists(dirs.base)) { - debug("creating base images folder at " ++ dirs.base); - FileUtil.mkdir(~parent=true, ~mode=`Octal(0o755), dirs.base); - }; - - debug("(re)creating " ++ dirs.updated); - FileUtil.rm(~recurse=true, [dirs.updated]); - FileUtil.mkdir(~parent=true, ~mode=`Octal(0o755), dirs.updated); - - debug("(re)creating " ++ dirs.diff); - FileUtil.rm(~recurse=true, [dirs.diff]); - FileUtil.mkdir(~parent=true, ~mode=`Octal(0o755), dirs.diff); -}; - -let setup = (~noCreate, ~noOnly, ~noSkip, ~parallelism, ~config_path) => { - open Config.Types; - open Lwt_result.Syntax; - - let debug = Logger.debug(~header="SETUP"); - - let start_time = Unix.gettimeofday(); - - let* config = Config.Global.init(~config_path) |> Lwt_result.lift; - let config = - switch (parallelism) { - | Some(parallelism) => {...config, parallelism} - | None => config - }; - - let () = init_folder_structure(config); - let snapshot_dir = OSnap_Paths.get_base_images_dir(config); - debug("looking for test files"); - let* tests = Config.Test.init(config) |> Lwt_result.lift; - debug(Printf.sprintf("found %i test files", List.length(tests))); - - debug("collecting test sizes to run"); - let* all_tests = - tests - |> Lwt_list.map_p_until_exception(test => { - test.sizes - |> Lwt_list.map_p_until_exception(size => { - let {name: _size_name, width, height} = size; - let filename = - OSnap_Test.get_filename(test.name, width, height); - let current_image_path = snapshot_dir ++ filename; - let exists = Sys.file_exists(current_image_path); - - if (noCreate && !exists) { - Lwt_result.fail( - OSnap_Response.Invalid_Run( - Printf.sprintf( - "Flag --no-create is set. Cannot create new images for %s.", - test.name, - ), - ), - ); - } else { - Lwt_result.return((test, size, exists)); - }; - }) - }) - |> Lwt_result.map(List.flatten); - - debug("checking for \"only\" flags"); - let only_tests = all_tests |> List.find_all(((test, _, _)) => test.only); - - let* tests_to_run = - if (noOnly && List.length(only_tests) > 0) { - Lwt_result.fail( - OSnap_Response.Invalid_Run( - only_tests - |> List.map(((test: Config.Types.test, _, _)) => test.name) - |> List.sort_uniq(String.compare) - |> String.concat(",\n") - |> Printf.sprintf( - "Flag --no-only is set, but the following tests still have only set to true:\n%s", - ), - ), - ); - } else if (List.length(only_tests) > 0) { - Lwt_result.return(only_tests); - } else { - Lwt_result.return(all_tests); - }; - - debug("checking for \"skip\" flags"); - let (skipped_tests, tests_to_run) = - tests_to_run |> List.partition(((test, _, _)) => test.skip); - - let* tests_to_run = - if (noSkip && List.length(skipped_tests) > 0) { - Lwt_result.fail( - OSnap_Response.Invalid_Run( - skipped_tests - |> List.map(((test: Config.Types.test, _, _)) => test.name) - |> List.sort_uniq(String.compare) - |> String.concat(",\n") - |> Printf.sprintf( - "Flag --no-skip is set, but the following tests still have \"skip\" set to true:\n%s", - ), - ), - ); - } else if (List.length(skipped_tests) > 0) { - skipped_tests - |> List.iter(((test: Config.Types.test, {width, height, _}, _)) => - Printer.skipped_message(~name=test.name, ~width, ~height) - ); - Lwt_result.return(tests_to_run); - } else { - Lwt_result.return(tests_to_run); - }; - - debug("setting test priority"); - let tests_to_run = - tests_to_run - |> List.fast_sort(((_test, _size, exists1), (_test, _size, exists2)) => { - Bool.compare(exists1, exists2) - }); - - debug("launching browser"); - let* browser = Browser.Launcher.make(); - - Lwt_result.return({config, all_tests, tests_to_run, start_time, browser}); -}; - -let teardown = t => { - Browser.Launcher.shutdown(t.browser); -}; - -let run = t => { - open Config.Types; - open Lwt_result.Syntax; - - let debug = Logger.debug(~header="RUN"); - let {tests_to_run, all_tests, config, start_time, browser} = t; - - let parallelism = max(1, config.parallelism); - debug(Printf.sprintf("creating pool of %i runners", parallelism)); - let pool = - Lwt_pool.create( - parallelism, - () => Browser.Target.make(browser), - ~validate=target => Lwt.return(Result.is_ok(target)), - ); - - let* test_results = - tests_to_run - |> Lwt_list.map_p_until_exception(test => { - Lwt_pool.use( - pool, - target => { - let (test, {name: size_name, width, height}, exists) = test; - - let test: OSnap_Test.t = { - exists, - size_name, - width, - height, - url: test.url, - name: test.name, - actions: test.actions, - ignore_regions: test.ignore, - threshold: test.threshold, - }; - - /* Targets are validated at creation time. They are guaranteed to be created. */ - OSnap_Test.run(config, Result.get_ok(target), test); - }, - ) - }); - - let end_time = Unix.gettimeofday(); - let seconds = end_time -. start_time; - - Browser.Launcher.shutdown(browser); - - let create_count = - test_results |> List.filter(r => r == `Created) |> List.length; - let passed_count = - test_results |> List.filter(r => r == `Passed) |> List.length; - let failed_tests = - test_results - |> List.filter_map( - fun - | `Passed - | `Created => None - | `Failed(_) as r => Some(r), - ); - let test_count = tests_to_run |> List.length; - - Printer.stats( - ~test_count, - ~create_count, - ~passed_count, - ~failed_tests, - ~skipped_count=List.length(all_tests) - test_count, - ~seconds, - ); - - switch (failed_tests) { - | [] => Lwt_result.return() - | _ => Lwt_result.fail(OSnap_Response.Test_Failure) - }; -}; - -let cleanup = (~config_path) => { - let ( let* ) = Result.bind; - - print_newline(); - - let* config = Config.Global.init(~config_path); - let () = init_folder_structure(config); - let snapshot_dir = OSnap_Paths.get_base_images_dir(config); - let* tests = Config.Test.init(config); - - let test_file_paths = - tests - |> List.map((test: Config.Types.test) => { - test.sizes - |> List.filter_map((size: Config.Types.size) => { - let Config.Types.{width, height, _} = size; - let filename = - OSnap_Test.get_filename(test.name, width, height); - let current_image_path = snapshot_dir ++ filename; - let exists = Sys.file_exists(current_image_path); - - if (exists) { - Some(current_image_path); - } else { - None; - }; - }) - }) - |> List.flatten; - - let files_to_delete = - FileUtil.ls(snapshot_dir) - |> List.find_all(file => !List.mem(file, test_file_paths)); - let num_files_to_delete = List.length(files_to_delete); - open Fmt; - if (num_files_to_delete > 0) { - Fmt.pr( - "%a @.", - styled(`Bold, string), - Printf.sprintf("Deleting %i files...\n", num_files_to_delete), - ); - files_to_delete - |> List.iter(file => { - FileUtil.rm([file]); - Fmt.pr( - "%a @.", - styled(`Faint, string), - Printf.sprintf("Deleted %s", file), - ); - }); - - Fmt.pr("\n%a @.", styled(`Bold, styled(`Green, string)), "Done!"); - } else { - Fmt.pr( - "%a @.", - styled(`Bold, styled(`Green, string)), - "Everything clean. No files to remove!", - ); - }; - - print_newline(); - - Result.ok(); -}; - -let download_chromium = OSnap_Browser.Download.download; diff --git a/lib/OSnap.rei b/lib/OSnap.rei deleted file mode 100644 index 763965b..0000000 --- a/lib/OSnap.rei +++ /dev/null @@ -1,22 +0,0 @@ -module Logger = OSnap_Logger; -module Utils = OSnap_Utils; - -type t; - -let setup: - ( - ~noCreate: bool, - ~noOnly: bool, - ~noSkip: bool, - ~parallelism: option(int), - ~config_path: string - ) => - Lwt_result.t(t, OSnap_Response.t); - -let teardown: t => unit; - -let cleanup: (~config_path: string) => Result.t(unit, OSnap_Response.t); - -let run: t => Lwt_result.t(unit, OSnap_Response.t); - -let download_chromium: unit => Lwt_result.t(unit, unit); diff --git a/lib/OSnap_Browser/OSnap_Browser.ml b/lib/OSnap_Browser/OSnap_Browser.ml new file mode 100644 index 0000000..540554c --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser.ml @@ -0,0 +1,5 @@ +module Actions = OSnap_Browser_Actions +module Launcher = OSnap_Browser_Launcher +module Target = OSnap_Browser_Target +module Download = OSnap_Browser_Download +include OSnap_Browser_Types \ No newline at end of file diff --git a/lib/OSnap_Browser/OSnap_Browser.mli b/lib/OSnap_Browser/OSnap_Browser.mli new file mode 100644 index 0000000..b311708 --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser.mli @@ -0,0 +1,95 @@ +type t + +module Launcher : sig + val make : unit -> (t, OSnap_Response.t) Lwt_result.t + val shutdown : t -> unit +end + +module Target : sig + type target = + { targetId : Cdp.Types.Target.TargetID.t + ; sessionId : Cdp.Types.Target.SessionID.t + } + + val make : t -> (target, OSnap_Response.t) Lwt_result.t +end + +module Actions : sig + val get_document + : Target.target + -> (Cdp.Commands.DOM.GetDocument.Response.result, OSnap_Response.t) Lwt_result.t + + val get_quads + : document:Cdp.Commands.DOM.GetDocument.Response.result + -> selector:string + -> Target.target + -> ((float * float) * (float * float), OSnap_Response.t) Lwt_result.t + + val get_quads_all + : document:Cdp.Commands.DOM.GetDocument.Response.result + -> selector:string + -> Target.target + -> (((float * float) * (float * float)) list, OSnap_Response.t) Lwt_result.t + + val scroll + : document:Cdp.Commands.DOM.GetDocument.Response.result + -> selector:string option + -> px:int option + -> Target.target + -> (unit, OSnap_Response.t) Lwt_result.t + + val mousemove + : document:Cdp.Commands.DOM.GetDocument.Response.result + -> to_:[ `Selector of string | `Coordinates of Cdp.Types.number * Cdp.Types.number ] + -> Target.target + -> (unit, OSnap_Response.t) Lwt_result.t + + val click + : document:Cdp.Commands.DOM.GetDocument.Response.result + -> selector:string + -> Target.target + -> (unit, OSnap_Response.t) Lwt_result.t + + val type_text + : document:Cdp.Commands.DOM.GetDocument.Response.result + -> selector:string + -> text:string + -> Target.target + -> (unit, OSnap_Response.t) Lwt_result.t + + val wait_for + : ?timeout:float + -> ?look_behind:bool + -> event:string + -> Target.target + -> [> `Data of string | `Timeout ] Lwt.t + + val wait_for_network_idle + : Target.target + -> loaderId:Cdp.Types.Network.LoaderId.t + -> unit Lwt.t + + val go_to : url:string -> Target.target -> (string, OSnap_Response.t) Lwt_result.t + + val get_content_size + : Target.target + -> (Cdp.Types.number * Cdp.Types.number, OSnap_Response.t) Lwt_result.t + + val set_size + : width:Cdp.Types.number + -> height:Cdp.Types.number + -> Target.target + -> (unit, OSnap_Response.t) Lwt_result.t + + val screenshot + : ?full_size:bool + -> Target.target + -> (string, OSnap_Response.t) Lwt_result.t + + val clear_cookies : Target.target -> (unit, OSnap_Response.t) Lwt_result.t +end + +module Download : sig + val get_uri : string -> OSnap_Utils.platform -> Uri.t + val download : unit -> (unit, unit) Lwt_result.t +end \ No newline at end of file diff --git a/lib/OSnap_Browser/OSnap_Browser.re b/lib/OSnap_Browser/OSnap_Browser.re deleted file mode 100644 index 2ccab7c..0000000 --- a/lib/OSnap_Browser/OSnap_Browser.re +++ /dev/null @@ -1,6 +0,0 @@ -module Actions = OSnap_Browser_Actions; -module Launcher = OSnap_Browser_Launcher; -module Target = OSnap_Browser_Target; -module Download = OSnap_Browser_Download; - -include OSnap_Browser_Types; diff --git a/lib/OSnap_Browser/OSnap_Browser.rei b/lib/OSnap_Browser/OSnap_Browser.rei deleted file mode 100644 index 354db86..0000000 --- a/lib/OSnap_Browser/OSnap_Browser.rei +++ /dev/null @@ -1,108 +0,0 @@ -type t; - -module Launcher: { - let make: unit => Lwt_result.t(t, OSnap_Response.t); - - let shutdown: t => unit; -}; - -module Target: { - type target = { - targetId: Cdp.Types.Target.TargetID.t, - sessionId: Cdp.Types.Target.SessionID.t, - }; - - let make: t => Lwt_result.t(target, OSnap_Response.t); -}; - -module Actions: { - let get_document: - Target.target => - Lwt_result.t( - Cdp.Commands.DOM.GetDocument.Response.result, - OSnap_Response.t, - ); - - let get_quads: - ( - ~document: Cdp.Commands.DOM.GetDocument.Response.result, - ~selector: string, - Target.target - ) => - Lwt_result.t(((float, float), (float, float)), OSnap_Response.t); - - let get_quads_all: - ( - ~document: Cdp.Commands.DOM.GetDocument.Response.result, - ~selector: string, - Target.target - ) => - Lwt_result.t(list(((float, float), (float, float))), OSnap_Response.t); - - let scroll: - ( - ~document: Cdp.Commands.DOM.GetDocument.Response.result, - ~selector: option(string), - ~px: option(int), - Target.target - ) => - Lwt_result.t(unit, OSnap_Response.t); - - let mousemove: - ( - ~document: Cdp.Commands.DOM.GetDocument.Response.result, - ~to_: [ - | `Selector(string) - | `Coordinates(Cdp.Types.number, Cdp.Types.number) - ], - Target.target - ) => - Lwt_result.t(unit, OSnap_Response.t); - - let click: - ( - ~document: Cdp.Commands.DOM.GetDocument.Response.result, - ~selector: string, - Target.target - ) => - Lwt_result.t(unit, OSnap_Response.t); - - let type_text: - ( - ~document: Cdp.Commands.DOM.GetDocument.Response.result, - ~selector: string, - ~text: string, - Target.target - ) => - Lwt_result.t(unit, OSnap_Response.t); - - let wait_for: - (~timeout: float=?, ~look_behind: bool=?, ~event: string, Target.target) => - Lwt.t([> | `Data(string) | `Timeout]); - - let wait_for_network_idle: - (Target.target, ~loaderId: Cdp.Types.Network.LoaderId.t) => Lwt.t(unit); - - let go_to: - (~url: string, Target.target) => Lwt_result.t(string, OSnap_Response.t); - - let get_content_size: - Target.target => - Lwt_result.t((Cdp.Types.number, Cdp.Types.number), OSnap_Response.t); - - let set_size: - (~width: Cdp.Types.number, ~height: Cdp.Types.number, Target.target) => - Lwt_result.t(unit, OSnap_Response.t); - - let screenshot: - (~full_size: bool=?, Target.target) => - Lwt_result.t(string, OSnap_Response.t); - - let clear_cookies: Target.target => Lwt_result.t(unit, OSnap_Response.t); -}; - -module Download: { - let get_uri: (string, OSnap_Utils.platform) => Uri.t; - - let download: unit => Lwt_result.t(unit, unit); -}; diff --git a/lib/OSnap_Browser/OSnap_Browser_Actions.ml b/lib/OSnap_Browser/OSnap_Browser_Actions.ml new file mode 100644 index 0000000..8197ee9 --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser_Actions.ml @@ -0,0 +1,453 @@ +open Cdp +open OSnap_Browser_Target +open Lwt_result.Syntax + +let wait_for ?timeout ?look_behind ~event target = + let sessionId = target.sessionId in + let p, resolver = Lwt.wait () in + let callback data remove = + remove (); + Lwt.wakeup_later resolver (`Data data) + in + OSnap_Websocket.listen ~event ?look_behind ~sessionId callback; + match timeout with + | None -> p + | Some t -> + let timeout = Lwt_unix.sleep (t /. 1000.) |> Lwt.map (fun () -> `Timeout) in + Lwt.pick [ timeout; p ] +;; + +let get_document target = + let sessionId = target.sessionId in + let open Commands.DOM.GetDocument in + Request.make ~sessionId ~params:(Params.make ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) +;; + +let select_element_all ~document ~selector ~sessionId = + let open Commands.DOM.QuerySelectorAll in + Request.make + ~sessionId + ~params: + (Params.make + ~nodeId:document.Commands.DOM.GetDocument.Response.root.nodeId + ~selector + ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + match response.Response.error, response.Response.result with + | _, Some { nodeIds = [] } -> + Result.error + (OSnap_Response.CDP_Protocol_Error + (Printf.sprintf "No node with the selector %S could not be found." selector)) + | None, None -> Result.error (OSnap_Response.CDP_Protocol_Error "") + | Some { message; _ }, None -> + Result.error (OSnap_Response.CDP_Protocol_Error message) + | Some _, Some result | None, Some result -> Result.ok result) +;; + +let select_element ~document ~selector ~sessionId = + let open Commands.DOM.QuerySelector in + Request.make + ~sessionId + ~params: + (Params.make + ~nodeId:document.Commands.DOM.GetDocument.Response.root.nodeId + ~selector + ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + match response.Response.error, response.Response.result with + | _, (Some { nodeId = `Int 0 } | Some { nodeId = `Float 0. }) -> + Result.error + (OSnap_Response.CDP_Protocol_Error + (Printf.sprintf "A node with the selector %S could not be found." selector)) + | None, None -> Result.error (OSnap_Response.CDP_Protocol_Error "") + | Some { message; _ }, None -> + Result.error (OSnap_Response.CDP_Protocol_Error message) + | Some _, Some result | None, Some result -> Result.ok result) +;; + +let wait_for_network_idle target ~loaderId = + let open Events.Page in + let sessionId = target.sessionId in + let p, resolver = Lwt.wait () in + OSnap_Websocket.listen ~event:LifecycleEvent.name ~sessionId (fun response remove -> + let eventData = LifecycleEvent.parse response in + if eventData.params.name = "networkIdle" && loaderId = eventData.params.loaderId + then ( + remove (); + Lwt.wakeup_later resolver ())); + p +;; + +let go_to ~url target = + let open Commands.Page in + let sessionId = target.sessionId in + let debug = OSnap_Logger.debug ~header:"Browser.go_to" in + debug (Printf.sprintf "session %S navigationg to %S" sessionId url); + let params = Navigate.Params.make ~url () in + let* result = + let open Navigate in + Request.make ~sessionId ~params + |> OSnap_Websocket.send + |> Lwt.map Navigate.Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + match result.errorText, result.loaderId with + | Some error, _ -> OSnap_Response.CDP_Protocol_Error error |> Lwt_result.fail + | None, None -> + Lwt_result.fail (OSnap_Response.CDP_Protocol_Error "CDP responded with no loader id") + | None, Some loaderId -> loaderId |> Lwt_result.return +;; + +let type_text ~document ~selector ~text target = + let open Commands.DOM in + let sessionId = target.sessionId in + let* node = select_element ~document ~selector ~sessionId in + let* () = + let open Focus in + Request.make ~sessionId ~params:(Params.make ~nodeId:node.nodeId ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + |> Lwt_result.map ignore + in + let* () = + List.init (String.length text) (String.get text) + |> Lwt_list.iter_s (fun char -> + let definition = + (OSnap_Browser_KeyDefinition.make char : OSnap_Browser_KeyDefinition.t option) + in + match definition with + | Some def -> + [%lwt + let () = + let open Commands.Input.DispatchKeyEvent in + Request.make + ~sessionId + ~params: + (Params.make + ~type_:`keyDown + ~windowsVirtualKeyCode: + (def.keyCode + |> Option.map (fun i -> `Int i) + |> Option.value ~default:(`Int 0)) + ~key:def.key + ~code:def.code + ~text:def.text + ~unmodifiedText:def.text + ~location:(`Int def.location) + ~isKeypad:(def.location = 3) + ()) + |> OSnap_Websocket.send + |> Lwt.map ignore + in + let open Commands.Input.DispatchKeyEvent in + Request.make + ~sessionId + ~params: + (Params.make + ~type_:`keyUp + ~key:def.key + ~code:def.code + ~location:(`Int def.location) + ()) + |> OSnap_Websocket.send + |> Lwt.map ignore] + | None -> Lwt.return ()) + |> Lwt_result.ok + in + let* wait_result = + wait_for ~event:"Page.frameNavigated" ~look_behind:false ~timeout:1000. target + |> Lwt_result.ok + in + match wait_result with + | `Timeout -> Lwt_result.return () + | `Data data -> + let event_data = Cdp.Events.Page.FrameNavigated.parse data in + let loaderId = event_data.params.frame.loaderId in + wait_for_network_idle target ~loaderId |> Lwt_result.ok +;; + +let get_quads_all ~document ~selector target = + let open Commands.DOM in + let sessionId = target.sessionId in + let to_float = function + | `Float f -> f + | `Int i -> float_of_int i + in + let* { nodeIds } = select_element_all ~document ~selector ~sessionId in + nodeIds + |> Lwt_list.fold_left_s + (fun acc nodeId -> + let open GetContentQuads in + Request.make ~sessionId ~params:(Params.make ~nodeId ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + match response.Response.error, response.Response.result with + | ( (None | Some _) + , Some + { quads = (x1 :: y1 :: x2 :: _y2 :: _x3 :: y2 :: _x4 :: _y4 :: _) :: _ + } ) -> ((to_float x1, to_float y1), (to_float x2, to_float y2)) :: acc + | _ -> acc)) + [] + |> Lwt_result.ok +;; + +let get_quads ~document ~selector target = + let open Commands.DOM in + let sessionId = target.sessionId in + let* { nodeId } = select_element ~document ~selector ~sessionId in + let* result = + let open GetContentQuads in + Request.make ~sessionId ~params:(Params.make ~nodeId ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + let to_float = function + | `Float f -> f + | `Int i -> float_of_int i + in + match result.quads with + | (x1 :: y1 :: x2 :: _y2 :: _x3 :: y2 :: _x4 :: _y4 :: _) :: _ -> + Lwt_result.return ((to_float x1, to_float y1), (to_float x2, to_float y2)) + | _ -> Lwt_result.fail (OSnap_Response.CDP_Protocol_Error "no content quads returned") +;; + +let mousemove ~document ~to_ target = + let open Commands.Input in + let sessionId = target.sessionId in + let* x, y = + match to_ with + | `Selector selector -> + let* (x1, y1), (x2, y2) = get_quads ~document ~selector target in + let x = `Float (x1 +. ((x2 -. x1) /. 2.0)) in + let y = `Float (y1 +. ((y2 -. y1) /. 2.0)) in + Lwt_result.return (x, y) + | `Coordinates (x, y) -> Lwt_result.return (x, y) + in + let open DispatchMouseEvent in + Request.make ~sessionId ~params:(Params.make ~x ~y ~type_:`mouseMoved ()) + |> OSnap_Websocket.send + |> Lwt.map ignore + |> Lwt_result.ok +;; + +let click ~document ~selector target = + let open Commands.Input in + let sessionId = target.sessionId in + let* (x1, y1), (x2, y2) = get_quads ~document ~selector target in + let x = `Float (x1 +. ((x2 -. x1) /. 2.0)) in + let y = `Float (y1 +. ((y2 -. y1) /. 2.0)) in + let* () = mousemove ~document ~to_:(`Coordinates (x, y)) target in + let* _ = + let open DispatchMouseEvent in + Request.make + ~sessionId + ~params: + (Params.make + ~type_:`mousePressed + ~button:`left + ~buttons:(`Int 1) + ~clickCount:(`Int 1) + ~x + ~y + ()) + |> OSnap_Websocket.send + |> Lwt.map ignore + |> Lwt_result.ok + in + let* () = + let open DispatchMouseEvent in + Request.make + ~sessionId + ~params: + (Params.make + ~type_:`mouseReleased + ~button:`left + ~buttons:(`Int 1) + ~clickCount:(`Int 1) + ~x + ~y + ()) + |> OSnap_Websocket.send + |> Lwt.map ignore + |> Lwt_result.ok + in + let* wait_result = + wait_for ~event:"Page.frameNavigated" ~look_behind:false ~timeout:1000. target + |> Lwt_result.ok + in + match wait_result with + | `Timeout -> Lwt_result.return () + | `Data data -> + let event_data = Cdp.Events.Page.FrameNavigated.parse data in + let loaderId = event_data.params.frame.loaderId in + wait_for_network_idle target ~loaderId |> Lwt_result.ok +;; + +let scroll ~document ~selector ~px target = + let sessionId = target.sessionId in + match px, selector with + | None, None -> assert false + | Some _, Some _ -> assert false + | None, Some selector -> + let* { nodeId } = select_element ~document ~selector ~sessionId in + let open Commands.DOM.ScrollIntoViewIfNeeded in + Request.make ~sessionId ~params:(Params.make ~nodeId ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + match response.Response.error with + | None -> Result.ok () + | Some { message; _ } -> Result.error (OSnap_Response.CDP_Protocol_Error message)) + | Some px, None -> + let expression = + Printf.sprintf + {| + window.scrollTo({ + top: %i, + left: 0, + behavior: 'smooth' + }); + |} + px + in + let open Commands.Runtime.Evaluate in + Request.make ~sessionId ~params:(Params.make ~expression ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> fun __x -> + Lwt.bind __x (fun response -> + match response.Response.error with + | None -> + let timeout = float_of_int (px / 200) in + Lwt_unix.sleep timeout |> Lwt_result.ok + | Some { message; _ } -> Lwt_result.fail (OSnap_Response.CDP_Protocol_Error message)) +;; + +let get_content_size target = + let open Commands.Page in + let sessionId = target.sessionId in + let* metrics = + let open GetLayoutMetrics in + Request.make ~sessionId + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + Lwt_result.return (metrics.cssContentSize.width, metrics.cssContentSize.height) +;; + +let set_size ~width ~height target = + let open Commands.Emulation in + let sessionId = target.sessionId in + let* _ = + let open SetDeviceMetricsOverride in + Request.make + ~sessionId + ~params:(Params.make ~width ~height ~deviceScaleFactor:(`Int 1) ~mobile:false ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + Lwt_result.return () +;; + +let screenshot ?(full_size = false) target = + let open Commands.Page in + let sessionId = target.sessionId in + let* () = + if full_size + then + let* width, height = get_content_size target in + set_size ~width ~height target + else Lwt_result.return () + in + let* result = + let open CaptureScreenshot in + Request.make + ~sessionId + ~params:(Params.make ~format:`png ~captureBeyondViewport:false ~fromSurface:true ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + Lwt_result.return result.data +;; + +let clear_cookies target = + let open Commands.Storage in + let sessionId = target.sessionId in + let* _ = + let open ClearCookies in + Request.make ~sessionId ~params:(Params.make ()) + |> OSnap_Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + Lwt_result.return () +;; diff --git a/lib/OSnap_Browser/OSnap_Browser_Actions.re b/lib/OSnap_Browser/OSnap_Browser_Actions.re deleted file mode 100644 index 0c7cf6d..0000000 --- a/lib/OSnap_Browser/OSnap_Browser_Actions.re +++ /dev/null @@ -1,630 +0,0 @@ -open Cdp; -open OSnap_Browser_Target; -open Lwt_result.Syntax; - -let wait_for = (~timeout=?, ~look_behind=?, ~event, target) => { - let sessionId = target.sessionId; - let (p, resolver) = Lwt.wait(); - - let callback = (data, remove) => { - remove(); - Lwt.wakeup_later(resolver, `Data(data)); - }; - - OSnap_Websocket.listen(~event, ~look_behind?, ~sessionId, callback); - switch (timeout) { - | None => p - | Some(t) => - let timeout = Lwt_unix.sleep(t /. 1000.) |> Lwt.map(() => `Timeout); - Lwt.pick([timeout, p]); - }; -}; - -let get_document = target => { - let sessionId = target.sessionId; - - Commands.DOM.GetDocument.( - Request.make(~sessionId, ~params=Params.make()) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); -}; - -let select_element_all = (~document, ~selector, ~sessionId) => { - Commands.DOM.QuerySelectorAll.( - Request.make( - ~sessionId, - ~params= - Params.make( - ~nodeId=document.Commands.DOM.GetDocument.Response.root.nodeId, - ~selector, - (), - ), - ) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - switch (response.Response.error, response.Response.result) { - | (_, Some({nodeIds: []})) => - Result.error( - OSnap_Response.CDP_Protocol_Error( - Printf.sprintf( - "No node with the selector %S could not be found.", - selector, - ), - ), - ) - | (None, None) => - Result.error(OSnap_Response.CDP_Protocol_Error("")) - | (Some({message, _}), None) => - Result.error(OSnap_Response.CDP_Protocol_Error(message)) - | (Some(_), Some(result)) - | (None, Some(result)) => Result.ok(result) - } - }) - ); -}; - -let select_element = (~document, ~selector, ~sessionId) => { - Commands.DOM.QuerySelector.( - Request.make( - ~sessionId, - ~params= - Params.make( - ~nodeId=document.Commands.DOM.GetDocument.Response.root.nodeId, - ~selector, - (), - ), - ) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - switch (response.Response.error, response.Response.result) { - | (_, Some({nodeId: `Int(0)}) | Some({nodeId: `Float(0.)})) => - Result.error( - OSnap_Response.CDP_Protocol_Error( - Printf.sprintf( - "A node with the selector %S could not be found.", - selector, - ), - ), - ) - | (None, None) => - Result.error(OSnap_Response.CDP_Protocol_Error("")) - | (Some({message, _}), None) => - Result.error(OSnap_Response.CDP_Protocol_Error(message)) - | (Some(_), Some(result)) - | (None, Some(result)) => Result.ok(result) - } - }) - ); -}; - -let wait_for_network_idle = (target, ~loaderId) => { - open Events.Page; - - let sessionId = target.sessionId; - let (p, resolver) = Lwt.wait(); - - OSnap_Websocket.listen( - ~event=LifecycleEvent.name, - ~sessionId, - (response, remove) => { - let eventData = LifecycleEvent.parse(response); - if (eventData.params.name == "networkIdle" - && loaderId == eventData.params.loaderId) { - remove(); - Lwt.wakeup_later(resolver, ()); - }; - }, - ); - - p; -}; - -let go_to = (~url, target) => { - open Commands.Page; - - let sessionId = target.sessionId; - - let debug = OSnap_Logger.debug(~header="Browser.go_to"); - debug(Printf.sprintf("session %S \n\t navigationg to %S", sessionId, url)); - - let params = Navigate.Params.make(~url, ()); - - let* result = - Navigate.( - Request.make(~sessionId, ~params) - |> OSnap_Websocket.send - |> Lwt.map(Navigate.Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - switch (result.errorText, result.loaderId) { - | (Some(error), _) => - OSnap_Response.CDP_Protocol_Error(error) |> Lwt_result.fail - | (None, None) => - Lwt_result.fail( - OSnap_Response.CDP_Protocol_Error("CDP responded with no loader id"), - ) - | (None, Some(loaderId)) => loaderId |> Lwt_result.return - }; -}; - -let type_text = (~document, ~selector, ~text, target) => { - open Commands.DOM; - - let sessionId = target.sessionId; - - let* node = select_element(~document, ~selector, ~sessionId); - - let* () = - Focus.( - Request.make(~sessionId, ~params=Params.make(~nodeId=node.nodeId, ())) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - |> Lwt_result.map(ignore) - ); - - let* () = - List.init(String.length(text), String.get(text)) - |> Lwt_list.iter_s(char => { - let definition: option(OSnap_Browser_KeyDefinition.t) = - OSnap_Browser_KeyDefinition.make(char); - switch (definition) { - | Some(def) => - let%lwt () = - Commands.Input.DispatchKeyEvent.( - Request.make( - ~sessionId, - ~params= - Params.make( - ~type_=`keyDown, - ~windowsVirtualKeyCode= - def.keyCode - |> Option.map(i => `Int(i)) - |> Option.value(~default=`Int(0)), - ~key=def.key, - ~code=def.code, - ~text=def.text, - ~unmodifiedText=def.text, - ~location=`Int(def.location), - ~isKeypad=def.location == 3, - (), - ), - ) - |> OSnap_Websocket.send - |> Lwt.map(ignore) - ); - - Commands.Input.DispatchKeyEvent.( - Request.make( - ~sessionId, - ~params= - Params.make( - ~type_=`keyUp, - ~key=def.key, - ~code=def.code, - ~location=`Int(def.location), - (), - ), - ) - |> OSnap_Websocket.send - |> Lwt.map(ignore) - ); - | None => Lwt.return() - }; - }) - |> Lwt_result.ok; - - let* wait_result = - wait_for( - ~event="Page.frameNavigated", - ~look_behind=false, - ~timeout=1000., - target, - ) - |> Lwt_result.ok; - - switch (wait_result) { - | `Timeout => Lwt_result.return() - | `Data(data) => - let event_data = Cdp.Events.Page.FrameNavigated.parse(data); - let loaderId = event_data.params.frame.loaderId; - wait_for_network_idle(target, ~loaderId) |> Lwt_result.ok; - }; -}; - -let get_quads_all = (~document, ~selector, target) => { - open Commands.DOM; - - let sessionId = target.sessionId; - - let to_float = - fun - | `Float(f) => f - | `Int(i) => float_of_int(i); - - let* {nodeIds} = select_element_all(~document, ~selector, ~sessionId); - - nodeIds - |> Lwt_list.fold_left_s( - (acc, nodeId) => { - GetContentQuads.( - Request.make(~sessionId, ~params=Params.make(~nodeId, ())) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - switch (response.Response.error, response.Response.result) { - | ( - None | Some(_), - Some({ - quads: [ - [x1, y1, x2, _y2, _x3, y2, _x4, _y4, ..._], - ..._, - ], - }), - ) => [ - ( - (to_float(x1), to_float(y1)), - (to_float(x2), to_float(y2)), - ), - ...acc, - ] - | _ => acc - } - }) - ) - }, - [], - ) - |> Lwt_result.ok; -}; - -let get_quads = (~document, ~selector, target) => { - open Commands.DOM; - - let sessionId = target.sessionId; - - let* {nodeId} = select_element(~document, ~selector, ~sessionId); - - let* result = - GetContentQuads.( - Request.make(~sessionId, ~params=Params.make(~nodeId, ())) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - let to_float = - fun - | `Float(f) => f - | `Int(i) => float_of_int(i); - - switch (result.quads) { - | [[x1, y1, x2, _y2, _x3, y2, _x4, _y4, ..._], ..._] => - Lwt_result.return(( - (to_float(x1), to_float(y1)), - (to_float(x2), to_float(y2)), - )) - | _ => - Lwt_result.fail( - OSnap_Response.CDP_Protocol_Error("no content quads returned"), - ) - }; -}; - -let mousemove = (~document, ~to_, target) => { - open Commands.Input; - - let sessionId = target.sessionId; - - let* (x, y) = - switch (to_) { - | `Selector(selector) => - let* ((x1, y1), (x2, y2)) = get_quads(~document, ~selector, target); - - let x = `Float(x1 +. (x2 -. x1) /. 2.0); - let y = `Float(y1 +. (y2 -. y1) /. 2.0); - Lwt_result.return((x, y)); - | `Coordinates(x, y) => Lwt_result.return((x, y)) - }; - - DispatchMouseEvent.( - Request.make( - ~sessionId, - ~params=Params.make(~x, ~y, ~type_=`mouseMoved, ()), - ) - |> OSnap_Websocket.send - |> Lwt.map(ignore) - |> Lwt_result.ok - ); -}; - -let click = (~document, ~selector, target) => { - open Commands.Input; - - let sessionId = target.sessionId; - - let* ((x1, y1), (x2, y2)) = get_quads(~document, ~selector, target); - - let x = `Float(x1 +. (x2 -. x1) /. 2.0); - let y = `Float(y1 +. (y2 -. y1) /. 2.0); - - let* () = mousemove(~document, ~to_=`Coordinates((x, y)), target); - - let* _ = - DispatchMouseEvent.( - Request.make( - ~sessionId, - ~params= - Params.make( - ~type_=`mousePressed, - ~button=`left, - ~buttons=`Int(1), - ~clickCount=`Int(1), - ~x, - ~y, - (), - ), - ) - |> OSnap_Websocket.send - |> Lwt.map(ignore) - |> Lwt_result.ok - ); - - let* () = - DispatchMouseEvent.( - Request.make( - ~sessionId, - ~params= - Params.make( - ~type_=`mouseReleased, - ~button=`left, - ~buttons=`Int(1), - ~clickCount=`Int(1), - ~x, - ~y, - (), - ), - ) - |> OSnap_Websocket.send - |> Lwt.map(ignore) - |> Lwt_result.ok - ); - - let* wait_result = - wait_for( - ~event="Page.frameNavigated", - ~look_behind=false, - ~timeout=1000., - target, - ) - |> Lwt_result.ok; - - switch (wait_result) { - | `Timeout => Lwt_result.return() - | `Data(data) => - let event_data = Cdp.Events.Page.FrameNavigated.parse(data); - let loaderId = event_data.params.frame.loaderId; - wait_for_network_idle(target, ~loaderId) |> Lwt_result.ok; - }; -}; - -let scroll = (~document, ~selector, ~px, target) => { - let sessionId = target.sessionId; - - switch (px, selector) { - | (None, None) => assert(false) - | (Some(_), Some(_)) => assert(false) - | (None, Some(selector)) => - let* {nodeId} = select_element(~document, ~selector, ~sessionId); - Commands.DOM.ScrollIntoViewIfNeeded.( - Request.make(~sessionId, ~params=Params.make(~nodeId, ())) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - switch (response.Response.error) { - | None => Result.ok() - | Some({message, _}) => - Result.error(OSnap_Response.CDP_Protocol_Error(message)) - } - }) - ); - | (Some(px), None) => - let expression = - Printf.sprintf( - {| - window.scrollTo({ - top: %i, - left: 0, - behavior: 'smooth' - }); - |}, - px, - ); - - Commands.Runtime.Evaluate.( - Request.make(~sessionId, ~params=Params.make(~expression, ())) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.bind(_, response => { - switch (response.Response.error) { - | None => - let timeout = float_of_int(px / 200); - Lwt_unix.sleep(timeout) |> Lwt_result.ok; - | Some({message, _}) => - Lwt_result.fail(OSnap_Response.CDP_Protocol_Error(message)) - } - }) - ); - }; -}; - -let get_content_size = target => { - open Commands.Page; - - let sessionId = target.sessionId; - - let* metrics = - GetLayoutMetrics.( - Request.make(~sessionId) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - Lwt_result.return(( - metrics.cssContentSize.width, - metrics.cssContentSize.height, - )); -}; - -let set_size = (~width, ~height, target) => { - open Commands.Emulation; - - let sessionId = target.sessionId; - - let* _ = - SetDeviceMetricsOverride.( - Request.make( - ~sessionId, - ~params= - Params.make( - ~width, - ~height, - ~deviceScaleFactor=`Int(1), - ~mobile=false, - (), - ), - ) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - Lwt_result.return(); -}; - -let screenshot = (~full_size=false, target) => { - open Commands.Page; - - let sessionId = target.sessionId; - - let* () = - if (full_size) { - let* (width, height) = get_content_size(target); - set_size(~width, ~height, target); - } else { - Lwt_result.return(); - }; - - let* result = - CaptureScreenshot.( - Request.make( - ~sessionId, - ~params= - Params.make( - ~format=`png, - ~captureBeyondViewport=false, - ~fromSurface=true, - (), - ), - ) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - Lwt_result.return(result.data); -}; - -let clear_cookies = target => { - open Commands.Storage; - - let sessionId = target.sessionId; - - let* _ = - ClearCookies.( - Request.make(~sessionId, ~params=Params.make()) - |> OSnap_Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - Lwt_result.return(); -}; diff --git a/lib/OSnap_Browser/OSnap_Browser_Download.ml b/lib/OSnap_Browser/OSnap_Browser_Download.ml new file mode 100644 index 0000000..dcba9c1 --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser_Download.ml @@ -0,0 +1,88 @@ +open Cohttp_lwt_unix + +let get_uri revision (platform : OSnap_Utils.platform) = + Uri.make + ~scheme:"http" + ~host:"storage.googleapis.com" + ~port:80 + ~path: + (match platform with + | MacOS -> "/chromium-browser-snapshots/Mac/" ^ revision ^ "/chrome-mac.zip" + | MacOS_ARM -> + "/chromium-browser-snapshots/Mac_Arm/" ^ revision ^ "/chrome-mac.zip" + | Linux -> + "/chromium-browser-snapshots/Linux_x64/" ^ revision ^ "/chrome-linux.zip" + | Win64 -> "/chromium-browser-snapshots/Win_x64/" ^ revision ^ "/chrome-win.zip" + | Win32 -> + print_endline "Error: x86 is currently not supported on Windows"; + exit 1) + () +;; + +let download ~revision dir = + let open Lwt.Syntax in + let zip_path = Filename.concat dir "chromium.zip" in + let* io = Lwt_io.open_file ~mode:Output zip_path in + print_endline + (Printf.sprintf "Downloading Chrome Revision %s. This could take a while..." revision); + let uri = get_uri revision (OSnap_Utils.detect_platform ()) in + let* response, body = Client.get uri in + match response with + | { status = `OK; _ } -> + let* () = Cohttp_lwt.Body.to_stream body |> Lwt_stream.iter_s (Lwt_io.write io) in + let* () = Lwt_io.close io in + zip_path |> Lwt_result.return + | response -> + let* () = Lwt_io.close io in + Format.fprintf + Format.err_formatter + "Chrome could not be downloaded:\n%a\n%!" + Response.pp_hum + response; + Lwt_result.fail () +;; + +let extract_zip ?(dest = "") source = + let extract_entry in_file (entry : Zip.entry) = + let out_file = Filename.concat dest entry.name in + if entry.is_directory && not (Sys.file_exists out_file) + then FileUtil.mkdir ~parent:true ~mode:(`Octal 511) out_file + else ( + let parent_dir = FilePath.dirname out_file in + if not (Sys.file_exists parent_dir) + then FileUtil.mkdir ~parent:true ~mode:(`Octal 511) parent_dir; + let oc = open_out_gen [ Open_creat; Open_binary; Open_append ] 511 out_file in + try + Zip.copy_entry_to_channel in_file entry oc; + close_out oc + with + | err -> + close_out oc; + Sys.remove out_file; + raise err) + in + print_endline "Extracting Chromium..."; + let ic = Zip.open_in source in + ic |> Zip.entries |> List.iter (extract_entry ic); + Zip.close_in ic +;; + +let download () = + let open Lwt_result.Syntax in + let revision = OSnap_Browser_Path.get_revision () in + let extract_path = OSnap_Browser_Path.get_chromium_path () in + print_newline (); + print_newline (); + if (not (Sys.file_exists extract_path)) || not (Sys.is_directory extract_path) + then ( + Unix.mkdir extract_path 511; + Lwt_io.with_temp_dir ~prefix:"osnap_chromium_" (fun dir -> + let* path = dir |> download ~revision in + extract_zip path ~dest:extract_path; + print_endline "Done!"; + Lwt_result.return ())) + else ( + print_endline + (Printf.sprintf "Found Chromium at \"%s\". Skipping Download!" extract_path); + Lwt_result.return ()) +;; diff --git a/lib/OSnap_Browser/OSnap_Browser_Download.re b/lib/OSnap_Browser/OSnap_Browser_Download.re deleted file mode 100644 index d88b361..0000000 --- a/lib/OSnap_Browser/OSnap_Browser_Download.re +++ /dev/null @@ -1,123 +0,0 @@ -open Cohttp_lwt_unix; - -let get_uri = (revision, platform: OSnap_Utils.platform) => { - Uri.make( - ~scheme="http", - ~host="storage.googleapis.com", - ~port=80, - ~path= - switch (platform) { - | MacOS => - "/chromium-browser-snapshots/Mac/" ++ revision ++ "/chrome-mac.zip" - | MacOS_ARM => - "/chromium-browser-snapshots/Mac_Arm/" ++ revision ++ "/chrome-mac.zip" - | Linux => - "/chromium-browser-snapshots/Linux_x64/" - ++ revision - ++ "/chrome-linux.zip" - | Win64 => - "/chromium-browser-snapshots/Win_x64/" ++ revision ++ "/chrome-win.zip" - | Win32 => - print_endline("Error: x86 is currently not supported on Windows"); - exit(1); - }, - (), - ); -}; - -let download = (~revision, dir) => { - open Lwt.Syntax; - - let zip_path = Filename.concat(dir, "chromium.zip"); - let* io = Lwt_io.open_file(~mode=Output, zip_path); - - print_endline( - Printf.sprintf( - "Downloading Chrome Revision %s. This could take a while...", - revision, - ), - ); - - let uri = get_uri(revision, OSnap_Utils.detect_platform()); - - let* (response, body) = Client.get(uri); - - switch (response) { - | {status: `OK, _} => - let* () = - Cohttp_lwt.Body.to_stream(body) |> Lwt_stream.iter_s(Lwt_io.write(io)); - let* () = Lwt_io.close(io); - zip_path |> Lwt_result.return; - | response => - let* () = Lwt_io.close(io); - Format.fprintf( - Format.err_formatter, - "Chrome could not be downloaded:\n%a\n%!", - Response.pp_hum, - response, - ); - Lwt_result.fail(); - }; -}; - -let extract_zip = (~dest="", source) => { - let extract_entry = (in_file, entry: Zip.entry) => { - let out_file = Filename.concat(dest, entry.name); - if (entry.is_directory && !Sys.file_exists(out_file)) { - FileUtil.mkdir(~parent=true, ~mode=`Octal(511), out_file); - } else { - let parent_dir = FilePath.dirname(out_file); - if (!Sys.file_exists(parent_dir)) { - FileUtil.mkdir(~parent=true, ~mode=`Octal(511), parent_dir); - }; - - let oc = - open_out_gen([Open_creat, Open_binary, Open_append], 511, out_file); - try( - { - Zip.copy_entry_to_channel(in_file, entry, oc); - close_out(oc); - } - ) { - | err => - close_out(oc); - Sys.remove(out_file); - raise(err); - }; - }; - }; - - print_endline("Extracting Chromium..."); - - let ic = Zip.open_in(source); - ic |> Zip.entries |> List.iter(extract_entry(ic)); - Zip.close_in(ic); -}; - -let download = () => { - open Lwt_result.Syntax; - - let revision = OSnap_Browser_Path.get_revision(); - let extract_path = OSnap_Browser_Path.get_chromium_path(); - - print_newline(); - print_newline(); - - if (!Sys.file_exists(extract_path) || !Sys.is_directory(extract_path)) { - Unix.mkdir(extract_path, 511); - Lwt_io.with_temp_dir(~prefix="osnap_chromium_", dir => { - let* path = dir |> download(~revision); - extract_zip(path, ~dest=extract_path); - print_endline("Done!"); - Lwt_result.return(); - }); - } else { - print_endline( - Printf.sprintf( - "Found Chromium at \"%s\". Skipping Download!", - extract_path, - ), - ); - Lwt_result.return(); - }; -}; diff --git a/lib/OSnap_Browser/OSnap_Browser_KeyDefinition.ml b/lib/OSnap_Browser/OSnap_Browser_KeyDefinition.ml new file mode 100644 index 0000000..e9b5c3c --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser_KeyDefinition.ml @@ -0,0 +1,153 @@ +type t = + { keyCode : int option + ; key : string + ; code : string + ; text : string + ; location : int + } + +let make = function + | '0' -> + Some { keyCode = Some 48; key = "0"; code = "Digit0"; text = "0"; location = 0 } + | '1' -> + Some { keyCode = Some 49; key = "1"; code = "Digit1"; text = "1"; location = 0 } + | '2' -> + Some { keyCode = Some 50; key = "2"; code = "Digit2"; text = "2"; location = 0 } + | '3' -> + Some { keyCode = Some 51; key = "3"; code = "Digit3"; text = "3"; location = 0 } + | '4' -> + Some { keyCode = Some 52; key = "4"; code = "Digit4"; text = "4"; location = 0 } + | '5' -> + Some { keyCode = Some 53; key = "5"; code = "Digit5"; text = "5"; location = 0 } + | '6' -> + Some { keyCode = Some 54; key = "6"; code = "Digit6"; text = "6"; location = 0 } + | '7' -> + Some { keyCode = Some 55; key = "7"; code = "Digit7"; text = "7"; location = 0 } + | '8' -> + Some { keyCode = Some 56; key = "8"; code = "Digit8"; text = "8"; location = 0 } + | '9' -> + Some { keyCode = Some 57; key = "9"; code = "Digit9"; text = "9"; location = 0 } + | ' ' -> Some { keyCode = Some 32; key = " "; code = "Space"; text = " "; location = 0 } + | 'a' -> Some { keyCode = Some 65; key = "a"; code = "KeyA"; text = "a"; location = 0 } + | 'b' -> Some { keyCode = Some 66; key = "b"; code = "KeyB"; text = "b"; location = 0 } + | 'c' -> Some { keyCode = Some 67; key = "c"; code = "KeyC"; text = "c"; location = 0 } + | 'd' -> Some { keyCode = Some 68; key = "d"; code = "KeyD"; text = "d"; location = 0 } + | 'e' -> Some { keyCode = Some 69; key = "e"; code = "KeyE"; text = "e"; location = 0 } + | 'f' -> Some { keyCode = Some 70; key = "f"; code = "KeyF"; text = "f"; location = 0 } + | 'g' -> Some { keyCode = Some 71; key = "g"; code = "KeyG"; text = "g"; location = 0 } + | 'h' -> Some { keyCode = Some 72; key = "h"; code = "KeyH"; text = "h"; location = 0 } + | 'i' -> Some { keyCode = Some 73; key = "i"; code = "KeyI"; text = "i"; location = 0 } + | 'j' -> Some { keyCode = Some 74; key = "j"; code = "KeyJ"; text = "j"; location = 0 } + | 'k' -> Some { keyCode = Some 75; key = "k"; code = "KeyK"; text = "k"; location = 0 } + | 'l' -> Some { keyCode = Some 76; key = "l"; code = "KeyL"; text = "l"; location = 0 } + | 'm' -> Some { keyCode = Some 77; key = "m"; code = "KeyM"; text = "m"; location = 0 } + | 'n' -> Some { keyCode = Some 78; key = "n"; code = "KeyN"; text = "n"; location = 0 } + | 'o' -> Some { keyCode = Some 79; key = "o"; code = "KeyO"; text = "o"; location = 0 } + | 'p' -> Some { keyCode = Some 80; key = "p"; code = "KeyP"; text = "p"; location = 0 } + | 'q' -> Some { keyCode = Some 81; key = "q"; code = "KeyQ"; text = "q"; location = 0 } + | 'r' -> Some { keyCode = Some 82; key = "r"; code = "KeyR"; text = "r"; location = 0 } + | 's' -> Some { keyCode = Some 83; key = "s"; code = "KeyS"; text = "s"; location = 0 } + | 't' -> Some { keyCode = Some 84; key = "t"; code = "KeyT"; text = "t"; location = 0 } + | 'u' -> Some { keyCode = Some 85; key = "u"; code = "KeyU"; text = "u"; location = 0 } + | 'v' -> Some { keyCode = Some 86; key = "v"; code = "KeyV"; text = "v"; location = 0 } + | 'w' -> Some { keyCode = Some 87; key = "w"; code = "KeyW"; text = "w"; location = 0 } + | 'x' -> Some { keyCode = Some 88; key = "x"; code = "KeyX"; text = "x"; location = 0 } + | 'y' -> Some { keyCode = Some 89; key = "y"; code = "KeyY"; text = "y"; location = 0 } + | 'z' -> Some { keyCode = Some 90; key = "z"; code = "KeyZ"; text = "z"; location = 0 } + | '*' -> + Some + { keyCode = Some 106; key = "*"; code = "NumpadMultiply"; text = "*"; location = 3 } + | '+' -> + Some { keyCode = Some 107; key = "+"; code = "NumpadAdd"; text = "+"; location = 3 } + | '-' -> + Some + { keyCode = Some 109; key = "-"; code = "NumpadSubtract"; text = "-"; location = 3 } + | '/' -> + Some + { keyCode = Some 111; key = "/"; code = "NumpadDivide"; text = "/"; location = 3 } + | ';' -> + Some { keyCode = Some 186; key = ";"; code = "Semicolon"; text = ";"; location = 0 } + | '=' -> + Some { keyCode = Some 187; key = "="; code = "Equal"; text = "="; location = 0 } + | ',' -> + Some { keyCode = Some 188; key = ","; code = "Comma"; text = ","; location = 0 } + | '.' -> + Some { keyCode = Some 190; key = "."; code = "Period"; text = "."; location = 0 } + | '`' -> + Some { keyCode = Some 192; key = "`"; code = "Backquote"; text = "`"; location = 0 } + | '[' -> + Some { keyCode = Some 219; key = "["; code = "BracketLeft"; text = "["; location = 0 } + | '\\' -> + Some { keyCode = Some 220; key = "\\"; code = "Backslash"; text = "\\"; location = 0 } + | ']' -> + Some + { keyCode = Some 221; key = "]"; code = "BracketRight"; text = "]"; location = 0 } + | '\'' -> + Some { keyCode = Some 222; key = "'"; code = "Quote"; text = "'"; location = 0 } + | ')' -> + Some { keyCode = Some 48; key = ")"; code = "Digit0"; text = ")"; location = 0 } + | '!' -> + Some { keyCode = Some 49; key = "!"; code = "Digit1"; text = "!"; location = 0 } + | '@' -> + Some { keyCode = Some 50; key = "@"; code = "Digit2"; text = "@"; location = 0 } + | '#' -> + Some { keyCode = Some 51; key = "#"; code = "Digit3"; text = "#"; location = 0 } + | '$' -> + Some { keyCode = Some 52; key = "$"; code = "Digit4"; text = "$"; location = 0 } + | '%' -> + Some { keyCode = Some 53; key = "%"; code = "Digit5"; text = "%"; location = 0 } + | '^' -> + Some { keyCode = Some 54; key = "^"; code = "Digit6"; text = "^"; location = 0 } + | '&' -> + Some { keyCode = Some 55; key = "&"; code = "Digit7"; text = "&"; location = 0 } + | '(' -> + Some { keyCode = Some 57; key = "("; code = "Digit9"; text = "("; location = 0 } + | 'A' -> Some { keyCode = Some 65; key = "A"; code = "KeyA"; text = "A"; location = 0 } + | 'B' -> Some { keyCode = Some 66; key = "B"; code = "KeyB"; text = "B"; location = 0 } + | 'C' -> Some { keyCode = Some 67; key = "C"; code = "KeyC"; text = "C"; location = 0 } + | 'D' -> Some { keyCode = Some 68; key = "D"; code = "KeyD"; text = "D"; location = 0 } + | 'E' -> Some { keyCode = Some 69; key = "E"; code = "KeyE"; text = "E"; location = 0 } + | 'F' -> Some { keyCode = Some 70; key = "F"; code = "KeyF"; text = "F"; location = 0 } + | 'G' -> Some { keyCode = Some 71; key = "G"; code = "KeyG"; text = "G"; location = 0 } + | 'H' -> Some { keyCode = Some 72; key = "H"; code = "KeyH"; text = "H"; location = 0 } + | 'I' -> Some { keyCode = Some 73; key = "I"; code = "KeyI"; text = "I"; location = 0 } + | 'J' -> Some { keyCode = Some 74; key = "J"; code = "KeyJ"; text = "J"; location = 0 } + | 'K' -> Some { keyCode = Some 75; key = "K"; code = "KeyK"; text = "K"; location = 0 } + | 'L' -> Some { keyCode = Some 76; key = "L"; code = "KeyL"; text = "L"; location = 0 } + | 'M' -> Some { keyCode = Some 77; key = "M"; code = "KeyM"; text = "M"; location = 0 } + | 'N' -> Some { keyCode = Some 78; key = "N"; code = "KeyN"; text = "N"; location = 0 } + | 'O' -> Some { keyCode = Some 79; key = "O"; code = "KeyO"; text = "O"; location = 0 } + | 'P' -> Some { keyCode = Some 80; key = "P"; code = "KeyP"; text = "P"; location = 0 } + | 'Q' -> Some { keyCode = Some 81; key = "Q"; code = "KeyQ"; text = "Q"; location = 0 } + | 'R' -> Some { keyCode = Some 82; key = "R"; code = "KeyR"; text = "R"; location = 0 } + | 'S' -> Some { keyCode = Some 83; key = "S"; code = "KeyS"; text = "S"; location = 0 } + | 'T' -> Some { keyCode = Some 84; key = "T"; code = "KeyT"; text = "T"; location = 0 } + | 'U' -> Some { keyCode = Some 85; key = "U"; code = "KeyU"; text = "U"; location = 0 } + | 'V' -> Some { keyCode = Some 86; key = "V"; code = "KeyV"; text = "V"; location = 0 } + | 'W' -> Some { keyCode = Some 87; key = "W"; code = "KeyW"; text = "W"; location = 0 } + | 'X' -> Some { keyCode = Some 88; key = "X"; code = "KeyX"; text = "X"; location = 0 } + | 'Y' -> Some { keyCode = Some 89; key = "Y"; code = "KeyY"; text = "Y"; location = 0 } + | 'Z' -> Some { keyCode = Some 90; key = "Z"; code = "KeyZ"; text = "Z"; location = 0 } + | ':' -> + Some { keyCode = Some 186; key = ":"; code = "Semicolon"; text = ":"; location = 0 } + | '<' -> + Some { keyCode = Some 188; key = "<"; code = "Comma"; text = "<"; location = 0 } + | '_' -> + Some { keyCode = Some 189; key = "_"; code = "Minus"; text = "_"; location = 0 } + | '>' -> + Some { keyCode = Some 190; key = ">"; code = "Period"; text = ">"; location = 0 } + | '?' -> + Some { keyCode = Some 191; key = "?"; code = "Slash"; text = "?"; location = 0 } + | '~' -> + Some { keyCode = Some 192; key = "~"; code = "Backquote"; text = "~"; location = 0 } + | '{' -> + Some { keyCode = Some 219; key = "{"; code = "BracketLeft"; text = "{"; location = 0 } + | '|' -> + Some { keyCode = Some 220; key = "|"; code = "Backslash"; text = "|"; location = 0 } + | '}' -> + Some + { keyCode = Some 221; key = "}"; code = "BracketRight"; text = "}"; location = 0 } + | '"' -> + Some { keyCode = Some 222; key = "\""; code = "Quote"; text = "\""; location = 0 } + | _ -> None +;; diff --git a/lib/OSnap_Browser/OSnap_Browser_KeyDefinition.re b/lib/OSnap_Browser/OSnap_Browser_KeyDefinition.re deleted file mode 100644 index 5bdea6d..0000000 --- a/lib/OSnap_Browser/OSnap_Browser_KeyDefinition.re +++ /dev/null @@ -1,459 +0,0 @@ -type t = { - keyCode: option(int), - key: string, - code: string, - text: string, - location: int, -}; - -let make = - fun - | '0' => - Some({ - keyCode: Some(48), - key: "0", - code: "Digit0", - text: "0", - location: 0, - }) - | '1' => - Some({ - keyCode: Some(49), - key: "1", - code: "Digit1", - text: "1", - location: 0, - }) - | '2' => - Some({ - keyCode: Some(50), - key: "2", - code: "Digit2", - text: "2", - location: 0, - }) - | '3' => - Some({ - keyCode: Some(51), - key: "3", - code: "Digit3", - text: "3", - location: 0, - }) - | '4' => - Some({ - keyCode: Some(52), - key: "4", - code: "Digit4", - text: "4", - location: 0, - }) - | '5' => - Some({ - keyCode: Some(53), - key: "5", - code: "Digit5", - text: "5", - location: 0, - }) - | '6' => - Some({ - keyCode: Some(54), - key: "6", - code: "Digit6", - text: "6", - location: 0, - }) - | '7' => - Some({ - keyCode: Some(55), - key: "7", - code: "Digit7", - text: "7", - location: 0, - }) - | '8' => - Some({ - keyCode: Some(56), - key: "8", - code: "Digit8", - text: "8", - location: 0, - }) - | '9' => - Some({ - keyCode: Some(57), - key: "9", - code: "Digit9", - text: "9", - location: 0, - }) - | ' ' => - Some({ - keyCode: Some(32), - key: " ", - code: "Space", - text: " ", - location: 0, - }) - | 'a' => - Some({keyCode: Some(65), key: "a", code: "KeyA", text: "a", location: 0}) - | 'b' => - Some({keyCode: Some(66), key: "b", code: "KeyB", text: "b", location: 0}) - | 'c' => - Some({keyCode: Some(67), key: "c", code: "KeyC", text: "c", location: 0}) - | 'd' => - Some({keyCode: Some(68), key: "d", code: "KeyD", text: "d", location: 0}) - | 'e' => - Some({keyCode: Some(69), key: "e", code: "KeyE", text: "e", location: 0}) - | 'f' => - Some({keyCode: Some(70), key: "f", code: "KeyF", text: "f", location: 0}) - | 'g' => - Some({keyCode: Some(71), key: "g", code: "KeyG", text: "g", location: 0}) - | 'h' => - Some({keyCode: Some(72), key: "h", code: "KeyH", text: "h", location: 0}) - | 'i' => - Some({keyCode: Some(73), key: "i", code: "KeyI", text: "i", location: 0}) - | 'j' => - Some({keyCode: Some(74), key: "j", code: "KeyJ", text: "j", location: 0}) - | 'k' => - Some({keyCode: Some(75), key: "k", code: "KeyK", text: "k", location: 0}) - | 'l' => - Some({keyCode: Some(76), key: "l", code: "KeyL", text: "l", location: 0}) - | 'm' => - Some({keyCode: Some(77), key: "m", code: "KeyM", text: "m", location: 0}) - | 'n' => - Some({keyCode: Some(78), key: "n", code: "KeyN", text: "n", location: 0}) - | 'o' => - Some({keyCode: Some(79), key: "o", code: "KeyO", text: "o", location: 0}) - | 'p' => - Some({keyCode: Some(80), key: "p", code: "KeyP", text: "p", location: 0}) - | 'q' => - Some({keyCode: Some(81), key: "q", code: "KeyQ", text: "q", location: 0}) - | 'r' => - Some({keyCode: Some(82), key: "r", code: "KeyR", text: "r", location: 0}) - | 's' => - Some({keyCode: Some(83), key: "s", code: "KeyS", text: "s", location: 0}) - | 't' => - Some({keyCode: Some(84), key: "t", code: "KeyT", text: "t", location: 0}) - | 'u' => - Some({keyCode: Some(85), key: "u", code: "KeyU", text: "u", location: 0}) - | 'v' => - Some({keyCode: Some(86), key: "v", code: "KeyV", text: "v", location: 0}) - | 'w' => - Some({keyCode: Some(87), key: "w", code: "KeyW", text: "w", location: 0}) - | 'x' => - Some({keyCode: Some(88), key: "x", code: "KeyX", text: "x", location: 0}) - | 'y' => - Some({keyCode: Some(89), key: "y", code: "KeyY", text: "y", location: 0}) - | 'z' => - Some({keyCode: Some(90), key: "z", code: "KeyZ", text: "z", location: 0}) - | '*' => - Some({ - keyCode: Some(106), - key: "*", - code: "NumpadMultiply", - text: "*", - location: 3, - }) - | '+' => - Some({ - keyCode: Some(107), - key: "+", - code: "NumpadAdd", - text: "+", - location: 3, - }) - | '-' => - Some({ - keyCode: Some(109), - key: "-", - code: "NumpadSubtract", - text: "-", - location: 3, - }) - | '/' => - Some({ - keyCode: Some(111), - key: "/", - code: "NumpadDivide", - text: "/", - location: 3, - }) - | ';' => - Some({ - keyCode: Some(186), - key: ";", - code: "Semicolon", - text: ";", - location: 0, - }) - | '=' => - Some({ - keyCode: Some(187), - key: "=", - code: "Equal", - text: "=", - location: 0, - }) - | ',' => - Some({ - keyCode: Some(188), - key: ",", - code: "Comma", - text: ",", - location: 0, - }) - | '.' => - Some({ - keyCode: Some(190), - key: ".", - code: "Period", - text: ".", - location: 0, - }) - | '`' => - Some({ - keyCode: Some(192), - key: "`", - code: "Backquote", - text: "`", - location: 0, - }) - | '[' => - Some({ - keyCode: Some(219), - key: "[", - code: "BracketLeft", - text: "[", - location: 0, - }) - | '\\' => - Some({ - keyCode: Some(220), - key: "\\", - code: "Backslash", - text: "\\", - location: 0, - }) - | ']' => - Some({ - keyCode: Some(221), - key: "]", - code: "BracketRight", - text: "]", - location: 0, - }) - | '\'' => - Some({ - keyCode: Some(222), - key: "'", - code: "Quote", - text: "'", - location: 0, - }) - | ')' => - Some({ - keyCode: Some(48), - key: ")", - code: "Digit0", - text: ")", - location: 0, - }) - | '!' => - Some({ - keyCode: Some(49), - key: "!", - code: "Digit1", - text: "!", - location: 0, - }) - | '@' => - Some({ - keyCode: Some(50), - key: "@", - code: "Digit2", - text: "@", - location: 0, - }) - | '#' => - Some({ - keyCode: Some(51), - key: "#", - code: "Digit3", - text: "#", - location: 0, - }) - | '$' => - Some({ - keyCode: Some(52), - key: "$", - code: "Digit4", - text: "$", - location: 0, - }) - | '%' => - Some({ - keyCode: Some(53), - key: "%", - code: "Digit5", - text: "%", - location: 0, - }) - | '^' => - Some({ - keyCode: Some(54), - key: "^", - code: "Digit6", - text: "^", - location: 0, - }) - | '&' => - Some({ - keyCode: Some(55), - key: "&", - code: "Digit7", - text: "&", - location: 0, - }) - | '(' => - Some({ - keyCode: Some(57), - key: "(", - code: "Digit9", - text: "(", - location: 0, - }) - | 'A' => - Some({keyCode: Some(65), key: "A", code: "KeyA", text: "A", location: 0}) - | 'B' => - Some({keyCode: Some(66), key: "B", code: "KeyB", text: "B", location: 0}) - | 'C' => - Some({keyCode: Some(67), key: "C", code: "KeyC", text: "C", location: 0}) - | 'D' => - Some({keyCode: Some(68), key: "D", code: "KeyD", text: "D", location: 0}) - | 'E' => - Some({keyCode: Some(69), key: "E", code: "KeyE", text: "E", location: 0}) - | 'F' => - Some({keyCode: Some(70), key: "F", code: "KeyF", text: "F", location: 0}) - | 'G' => - Some({keyCode: Some(71), key: "G", code: "KeyG", text: "G", location: 0}) - | 'H' => - Some({keyCode: Some(72), key: "H", code: "KeyH", text: "H", location: 0}) - | 'I' => - Some({keyCode: Some(73), key: "I", code: "KeyI", text: "I", location: 0}) - | 'J' => - Some({keyCode: Some(74), key: "J", code: "KeyJ", text: "J", location: 0}) - | 'K' => - Some({keyCode: Some(75), key: "K", code: "KeyK", text: "K", location: 0}) - | 'L' => - Some({keyCode: Some(76), key: "L", code: "KeyL", text: "L", location: 0}) - | 'M' => - Some({keyCode: Some(77), key: "M", code: "KeyM", text: "M", location: 0}) - | 'N' => - Some({keyCode: Some(78), key: "N", code: "KeyN", text: "N", location: 0}) - | 'O' => - Some({keyCode: Some(79), key: "O", code: "KeyO", text: "O", location: 0}) - | 'P' => - Some({keyCode: Some(80), key: "P", code: "KeyP", text: "P", location: 0}) - | 'Q' => - Some({keyCode: Some(81), key: "Q", code: "KeyQ", text: "Q", location: 0}) - | 'R' => - Some({keyCode: Some(82), key: "R", code: "KeyR", text: "R", location: 0}) - | 'S' => - Some({keyCode: Some(83), key: "S", code: "KeyS", text: "S", location: 0}) - | 'T' => - Some({keyCode: Some(84), key: "T", code: "KeyT", text: "T", location: 0}) - | 'U' => - Some({keyCode: Some(85), key: "U", code: "KeyU", text: "U", location: 0}) - | 'V' => - Some({keyCode: Some(86), key: "V", code: "KeyV", text: "V", location: 0}) - | 'W' => - Some({keyCode: Some(87), key: "W", code: "KeyW", text: "W", location: 0}) - | 'X' => - Some({keyCode: Some(88), key: "X", code: "KeyX", text: "X", location: 0}) - | 'Y' => - Some({keyCode: Some(89), key: "Y", code: "KeyY", text: "Y", location: 0}) - | 'Z' => - Some({keyCode: Some(90), key: "Z", code: "KeyZ", text: "Z", location: 0}) - | ':' => - Some({ - keyCode: Some(186), - key: ":", - code: "Semicolon", - text: ":", - location: 0, - }) - | '<' => - Some({ - keyCode: Some(188), - key: "<", - code: "Comma", - text: "<", - location: 0, - }) - | '_' => - Some({ - keyCode: Some(189), - key: "_", - code: "Minus", - text: "_", - location: 0, - }) - | '>' => - Some({ - keyCode: Some(190), - key: ">", - code: "Period", - text: ">", - location: 0, - }) - | '?' => - Some({ - keyCode: Some(191), - key: "?", - code: "Slash", - text: "?", - location: 0, - }) - | '~' => - Some({ - keyCode: Some(192), - key: "~", - code: "Backquote", - text: "~", - location: 0, - }) - | '{' => - Some({ - keyCode: Some(219), - key: "{", - code: "BracketLeft", - text: "{", - location: 0, - }) - | '|' => - Some({ - keyCode: Some(220), - key: "|", - code: "Backslash", - text: "|", - location: 0, - }) - | '}' => - Some({ - keyCode: Some(221), - key: "}", - code: "BracketRight", - text: "}", - location: 0, - }) - | '"' => - Some({ - keyCode: Some(222), - key: "\"", - code: "Quote", - text: "\"", - location: 0, - }) - | _ => None; diff --git a/lib/OSnap_Browser/OSnap_Browser_Launcher.ml b/lib/OSnap_Browser/OSnap_Browser_Launcher.ml new file mode 100644 index 0000000..3c2f364 --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser_Launcher.ml @@ -0,0 +1,97 @@ +open OSnap_Browser_Types +module Logger = OSnap_Logger +open Lwt_result.Syntax +module Websocket = OSnap_Websocket + +let debug = Logger.debug ~header:"BROWSER" + +let make () = + let base_path = OSnap_Browser_Path.get_chromium_path () in + let executable_path = + match OSnap_Utils.detect_platform () with + | MacOS | MacOS_ARM -> + Filename.concat base_path "chrome-mac/Chromium.app/Contents/MacOS/Chromium" + | Linux -> Filename.concat base_path "chrome-linux/chrome" + | Win64 -> Filename.concat base_path "chrome-win/chrome.exe" + | Win32 -> "" + in + debug (Printf.sprintf "Launching browser from %S" executable_path); + let process = + Lwt_process.open_process_full + ( "" + , [| executable_path + ; "about:blank" + ; "--headless" + ; "--no-sandbox" + ; "--hide-scrollbars" + ; "--remote-debugging-port=0" + ; "--mute-audio" + ; "--disable-gpu" + ; "--disable-background-networking" + ; "--enable-features=NetworkService,NetworkServiceInProcess" + ; "--disable-background-timer-throttling" + ; "--disable-backgrounding-occluded-windows" + ; "--disable-breakpad" + ; "--disable-client-side-phishing-detection" + ; "--disable-component-extensions-with-background-pages" + ; "--disable-default-apps" + ; "--disable-dev-shm-usage" + ; "--disable-extensions" + ; "--disable-features=Translate" + ; "--disable-hang-monitor" + ; "--disable-ipc-flooding-protection" + ; "--disable-popup-blocking" + ; "--disable-prompt-on-repost" + ; "--disable-renderer-backgrounding" + ; "--disable-sync" + ; "--force-color-profile=srgb" + ; "--metrics-recording-only" + ; "--no-first-run" + ; "--enable-automation" + ; "--password-store=basic" + ; "--use-mock-keychain" + ; "--enable-blink-features=IdleDetection" + |] ) + in + let rec get_ws_url proc = + match proc#state with + | Lwt_process.Running -> + Lwt.bind (Lwt_io.read_line proc#stderr) (fun line -> + debug (Printf.sprintf "STDERR: %S" line); + match line with + | line + when line |> OSnap_Utils.contains_substring ~search:"Cannot start http server" + -> + proc#terminate; + Lwt_result.fail OSnap_Response.CDP_Connection_Failed + | line when line |> OSnap_Utils.contains_substring ~search:"DevTools listening on" + -> + let offset = String.length "DevTools listening on " in + let len = String.length line in + let socket = String.sub line offset (len - offset) in + socket |> Lwt_result.return + | _ -> get_ws_url proc) + | Lwt_process.Exited _ -> Lwt_result.fail OSnap_Response.CDP_Connection_Failed + in + let* url = get_ws_url process in + debug (Printf.sprintf "Connecting to: %S" url); + let _ = Websocket.connect url in + debug (Printf.sprintf "Connected!"); + let* result = + let open Cdp.Commands.Target.CreateBrowserContext in + Request.make ?sessionId:None ~params:(Params.make ()) + |> Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:OSnap_Response.CDP_Connection_Failed + in + Option.to_result response.Response.result ~none:error) + in + Lwt_result.return { ws = url; process; browserContextId = result.browserContextId } +;; + +let shutdown browser = browser.process#terminate diff --git a/lib/OSnap_Browser/OSnap_Browser_Launcher.re b/lib/OSnap_Browser/OSnap_Browser_Launcher.re deleted file mode 100644 index 2a69737..0000000 --- a/lib/OSnap_Browser/OSnap_Browser_Launcher.re +++ /dev/null @@ -1,132 +0,0 @@ -open OSnap_Browser_Types; -module Logger = OSnap_Logger; -open Lwt_result.Syntax; - -module Websocket = OSnap_Websocket; - -let debug = Logger.debug(~header="BROWSER"); - -let make = () => { - let base_path = OSnap_Browser_Path.get_chromium_path(); - - let executable_path = - switch (OSnap_Utils.detect_platform()) { - | MacOS - | MacOS_ARM => - Filename.concat( - base_path, - "chrome-mac/Chromium.app/Contents/MacOS/Chromium", - ) - | Linux => Filename.concat(base_path, "chrome-linux/chrome") - | Win64 => Filename.concat(base_path, "chrome-win/chrome.exe") - | Win32 => "" - }; - - debug(Printf.sprintf("Launching browser from %S", executable_path)); - - let process = - Lwt_process.open_process_full(( - "", - [| - executable_path, - "about:blank", - "--headless", - "--no-sandbox", - "--hide-scrollbars", - "--remote-debugging-port=0", - "--mute-audio", - "--disable-gpu", - "--disable-background-networking", - "--enable-features=NetworkService,NetworkServiceInProcess", - "--disable-background-timer-throttling", - "--disable-backgrounding-occluded-windows", - "--disable-breakpad", - "--disable-client-side-phishing-detection", - "--disable-component-extensions-with-background-pages", - "--disable-default-apps", - "--disable-dev-shm-usage", - "--disable-extensions", - "--disable-features=Translate", - "--disable-hang-monitor", - "--disable-ipc-flooding-protection", - "--disable-popup-blocking", - "--disable-prompt-on-repost", - "--disable-renderer-backgrounding", - "--disable-sync", - "--force-color-profile=srgb", - "--metrics-recording-only", - "--no-first-run", - "--enable-automation", - "--password-store=basic", - "--use-mock-keychain", - "--enable-blink-features=IdleDetection", - |], - )); - - let rec get_ws_url = proc => { - switch (proc#state) { - | Lwt_process.Running => - Lwt.bind( - Lwt_io.read_line(proc#stderr), - line => { - debug(Printf.sprintf("STDERR: %S", line)); - switch (line) { - | line - when - line - |> OSnap_Utils.contains_substring( - ~search="Cannot start http server", - ) => - proc#terminate; - Lwt_result.fail(OSnap_Response.CDP_Connection_Failed); - | line - when - line - |> OSnap_Utils.contains_substring( - ~search="DevTools listening on", - ) => - let offset = String.length("DevTools listening on "); - let len = String.length(line); - let socket = String.sub(line, offset, len - offset); - socket |> Lwt_result.return; - | _ => get_ws_url(proc) - }; - }, - ) - | Lwt_process.Exited(_) => - Lwt_result.fail(OSnap_Response.CDP_Connection_Failed) - }; - }; - - let* url = get_ws_url(process); - - debug(Printf.sprintf("Connecting to: %S", url)); - let _ = Websocket.connect(url); - - debug(Printf.sprintf("Connected!")); - - let* result = - Cdp.Commands.Target.CreateBrowserContext.( - Request.make(~sessionId=?None, ~params=Params.make()) - |> Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Connection_Failed); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - Lwt_result.return({ - ws: url, - process, - browserContextId: result.browserContextId, - }); -}; - -let shutdown = browser => (browser.process)#terminate; diff --git a/lib/OSnap_Browser/OSnap_Browser_Path.ml b/lib/OSnap_Browser/OSnap_Browser_Path.ml new file mode 100644 index 0000000..0e69744 --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser_Path.ml @@ -0,0 +1,8 @@ +let get_revision () = "1056772" +let get_folder_name () = "osnap_chromium_" ^ get_revision () + +let get_chromium_path () = + match Sys.getenv_opt "HOME" with + | Some home when Sys.is_directory home -> Filename.concat home (get_folder_name ()) + | _ -> Filename.concat (Filename.get_temp_dir_name ()) (get_folder_name ()) +;; diff --git a/lib/OSnap_Browser/OSnap_Browser_Path.re b/lib/OSnap_Browser/OSnap_Browser_Path.re deleted file mode 100644 index 5c572a7..0000000 --- a/lib/OSnap_Browser/OSnap_Browser_Path.re +++ /dev/null @@ -1,10 +0,0 @@ -// https://omahaproxy.appspot.com/deps.json?version=99.0.4844.74 -let get_revision = () => "1056772"; -let get_folder_name = () => "osnap_chromium_" ++ get_revision(); -let get_chromium_path = () => { - switch (Sys.getenv_opt("HOME")) { - | Some(home) when Sys.is_directory(home) => - Filename.concat(home, get_folder_name()) - | _ => Filename.concat(Filename.get_temp_dir_name(), get_folder_name()) - }; -}; diff --git a/lib/OSnap_Browser/OSnap_Browser_Target.ml b/lib/OSnap_Browser/OSnap_Browser_Target.ml new file mode 100644 index 0000000..e5dccf3 --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser_Target.ml @@ -0,0 +1,98 @@ +open OSnap_Browser_Types +module Websocket = OSnap_Websocket + +type target = + { targetId : Cdp.Types.Target.TargetID.t + ; sessionId : Cdp.Types.Target.SessionID.t + } + +let enable_events t = + let open Cdp.Commands in + let open Lwt_result.Syntax in + let sessionId = t.sessionId in + let* _ = + let open Page.Enable in + Request.make ~sessionId + |> Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + let* _ = + let open DOM.Enable in + Request.make ~sessionId ~params:(Params.make ()) + |> Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + let* _ = + let open Page.SetLifecycleEventsEnabled in + Request.make ~sessionId ~params:(Params.make ~enabled:true ()) + |> Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + Lwt_result.return () +;; + +let make browser = + let open Lwt_result.Syntax in + let* { targetId } = + let open Cdp.Commands.Target.CreateTarget in + Request.make + ?sessionId:None + ~params: + (Params.make + ~url:"about:blank" + ~browserContextId:browser.browserContextId + ~newWindow:true + ()) + |> Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + let* { sessionId } = + let open Cdp.Commands.Target.AttachToTarget in + Request.make ?sessionId:None ~params:(Params.make ~targetId ~flatten:true ()) + |> Websocket.send + |> Lwt.map Response.parse + |> Lwt.map (fun response -> + let error = + response.Response.error + |> Option.map (fun (error : Response.error) -> + OSnap_Response.CDP_Protocol_Error error.message) + |> Option.value ~default:(OSnap_Response.CDP_Protocol_Error "") + in + Option.to_result response.Response.result ~none:error) + in + let t = { targetId; sessionId } in + let* () = enable_events t in + Lwt_result.return t +;; diff --git a/lib/OSnap_Browser/OSnap_Browser_Target.re b/lib/OSnap_Browser/OSnap_Browser_Target.re deleted file mode 100644 index 3260c71..0000000 --- a/lib/OSnap_Browser/OSnap_Browser_Target.re +++ /dev/null @@ -1,122 +0,0 @@ -open OSnap_Browser_Types; - -module Websocket = OSnap_Websocket; - -type target = { - targetId: Cdp.Types.Target.TargetID.t, - sessionId: Cdp.Types.Target.SessionID.t, -}; - -let enable_events = t => { - open Cdp.Commands; - open Lwt_result.Syntax; - - let sessionId = t.sessionId; - - let* _ = - Page.Enable.( - Request.make(~sessionId) - |> Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - let* _ = - DOM.Enable.( - Request.make(~sessionId, ~params=Params.make()) - |> Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - let* _ = - Page.SetLifecycleEventsEnabled.( - Request.make(~sessionId, ~params=Params.make(~enabled=true, ())) - |> Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - Lwt_result.return(); -}; - -let make = browser => { - open Lwt_result.Syntax; - - let* {targetId} = - Cdp.Commands.Target.CreateTarget.( - Request.make( - ~sessionId=?None, - ~params= - Params.make( - ~url="about:blank", - ~browserContextId=browser.browserContextId, - ~newWindow=true, - (), - ), - ) - |> Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - let* {sessionId} = - Cdp.Commands.Target.AttachToTarget.( - Request.make( - ~sessionId=?None, - ~params=Params.make(~targetId, ~flatten=true, ()), - ) - |> Websocket.send - |> Lwt.map(Response.parse) - |> Lwt.map(response => { - let error = - response.Response.error - |> Option.map((error: Response.error) => - OSnap_Response.CDP_Protocol_Error(error.message) - ) - |> Option.value(~default=OSnap_Response.CDP_Protocol_Error("")); - - Option.to_result(response.Response.result, ~none=error); - }) - ); - - let t = {targetId, sessionId}; - let* () = enable_events(t); - Lwt_result.return(t); -}; diff --git a/lib/OSnap_Browser/OSnap_Browser_Types.ml b/lib/OSnap_Browser/OSnap_Browser_Types.ml new file mode 100644 index 0000000..5ca0fa5 --- /dev/null +++ b/lib/OSnap_Browser/OSnap_Browser_Types.ml @@ -0,0 +1,5 @@ +type t = + { ws : string + ; browserContextId : Cdp.Types.Browser.BrowserContextID.t + ; process : Lwt_process.process_full + } diff --git a/lib/OSnap_Browser/OSnap_Browser_Types.re b/lib/OSnap_Browser/OSnap_Browser_Types.re deleted file mode 100644 index 7f35f3d..0000000 --- a/lib/OSnap_Browser/OSnap_Browser_Types.re +++ /dev/null @@ -1,5 +0,0 @@ -type t = { - ws: string, - browserContextId: Cdp.Types.Browser.BrowserContextID.t, - process: Lwt_process.process_full, -}; diff --git a/lib/OSnap_Config/OSnap_Config.ml b/lib/OSnap_Config/OSnap_Config.ml new file mode 100644 index 0000000..ea8c540 --- /dev/null +++ b/lib/OSnap_Config/OSnap_Config.ml @@ -0,0 +1,3 @@ +module Types = OSnap_Config_Types +module Global = OSnap_Config_Global +module Test = OSnap_Config_Test \ No newline at end of file diff --git a/lib/OSnap_Config/OSnap_Config.re b/lib/OSnap_Config/OSnap_Config.re deleted file mode 100644 index 43f115f..0000000 --- a/lib/OSnap_Config/OSnap_Config.re +++ /dev/null @@ -1,3 +0,0 @@ -module Types = OSnap_Config_Types; -module Global = OSnap_Config_Global; -module Test = OSnap_Config_Test; diff --git a/lib/OSnap_Config/OSnap_Config_Global.ml b/lib/OSnap_Config/OSnap_Config_Global.ml new file mode 100644 index 0000000..58d3a81 --- /dev/null +++ b/lib/OSnap_Config/OSnap_Config_Global.ml @@ -0,0 +1,426 @@ +open OSnap_Config_Types + +module YAML = struct + let ( let* ) = Result.bind + + let parse path = + let debug = OSnap_Logger.debug ~header:"Config.Global.YAML.parse" in + let config = OSnap_Utils.get_file_contents path in + let* yaml = + config + |> Yaml.of_string + |> Result.map_error (fun _ -> + OSnap_Response.Config_Parse_Error + (Printf.sprintf "YAML could not be parsed", Some path)) + in + let* base_url = yaml |> OSnap_Config_Utils.YAML.get_string "baseUrl" in + debug (Printf.sprintf "baseUrl is set to %S" base_url); + let* fullscreen = + yaml + |> OSnap_Config_Utils.YAML.get_bool_option "fullScreen" + |> Result.map (Option.value ~default:false) + in + debug (Printf.sprintf "fullScreen is set to %b" fullscreen); + let* threshold = + yaml + |> OSnap_Config_Utils.YAML.get_int_option "threshold" + |> Result.map (Option.value ~default:0) + in + debug (Printf.sprintf "threshold is set to %i" threshold); + let* ignore_patterns = + yaml + |> OSnap_Config_Utils.YAML.get_string_list_option "ignorePatterns" + |> Result.map (Option.value ~default:[ "**/node_modules/**" ]) + in + debug + (Printf.sprintf "ignore_patterns are set to %s" (String.concat "," ignore_patterns)); + let* default_sizes = + yaml + |> OSnap_Config_Utils.YAML.get_list_option + "defaultSizes" + ~parser:OSnap_Config_Utils.YAML.parse_size + |> Result.map (Option.value ~default:[]) + in + let* functions = + let f = yaml |> Yaml.Util.find_exn "functions" in + f + |> Option.map (fun f -> + f + |> Yaml.Util.keys_exn + |> OSnap_Utils.List.map_until_exception (fun key -> + let* actions = + f + |> OSnap_Config_Utils.YAML.get_list_option + key + ~parser:OSnap_Config_Utils.YAML.parse_action + |> Result.map (Option.value ~default:[]) + in + (key, actions) |> Result.ok)) + |> Option.value ~default:(Result.ok []) + in + let* snapshot_directory = + yaml + |> OSnap_Config_Utils.YAML.get_string_option "snapshotDirectory" + |> Result.map (Option.value ~default:"__snapshots__") + in + debug (Printf.sprintf "snapshot directory is set to %s" snapshot_directory); + let* parallelism = + yaml + |> OSnap_Config_Utils.YAML.get_int_option "parallelism" + |> Result.map (Option.value ~default:8) + |> Result.map (max 1) + in + debug (Printf.sprintf "parallelism is set to %i" parallelism); + let root_path = + String.sub path 0 (String.length path - String.length "osnap.config.yaml") + in + debug (Printf.sprintf "setting root path to %s" root_path); + let* test_pattern = + yaml + |> OSnap_Config_Utils.YAML.get_string_option "testPattern" + |> Result.map (Option.value ~default:"**/*.osnap.yaml") + in + debug (Printf.sprintf "test pattern is set to %s" test_pattern); + let* diff_pixel_color = + yaml + |> Yaml.Util.find "diffPixelColor" + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, Some path)) + |> Result.map + (Option.map (fun colors -> + let get_color = function + | `Float f -> Result.ok (int_of_float f) + | `String s -> Result.ok (int_of_string s) + | _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ("diffPixelColor does not have a correct format", Some path)) + in + let* r = + colors + |> Yaml.Util.find "r" + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, Some path)) + |> Result.map (Option.map get_color) + |> Result.map OSnap_Config_Utils.to_result_option + |> Result.join + |> Result.map (Option.value ~default:255) + in + let* g = + colors + |> Yaml.Util.find "g" + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, Some path)) + |> Result.map (Option.map get_color) + |> Result.map OSnap_Config_Utils.to_result_option + |> Result.join + |> Result.map (Option.value ~default:0) + in + let* b = + colors + |> Yaml.Util.find "b" + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, Some path)) + |> Result.map (Option.map get_color) + |> Result.map OSnap_Config_Utils.to_result_option + |> Result.join + |> Result.map (Option.value ~default:0) + in + Result.ok (r, g, b))) + |> Result.map OSnap_Config_Utils.to_result_option + |> Result.join + |> Result.map (Option.value ~default:(255, 0, 0)) + in + let r, g, b = diff_pixel_color in + debug (Printf.sprintf "diff pixel color is set to %i,%i,%i" r g b); + debug "looking for duplicate names in defined sizes"; + let duplicates = + default_sizes + |> List.filter (fun (s : OSnap_Config_Types.size) -> Option.is_some s.name) + |> OSnap_Utils.find_duplicates (fun (s : OSnap_Config_Types.size) -> s.name) + |> List.map (fun (s : OSnap_Config_Types.size) -> + let name = Option.value s.name ~default:"" in + debug (Printf.sprintf "found size with duplicate name %S" name); + name) + in + if List.length duplicates <> 0 + then Result.error (OSnap_Response.Config_Duplicate_Size_Names duplicates) + else ( + debug "did not find duplicates"; + Result.ok + { root_path + ; threshold + ; test_pattern + ; ignore_patterns + ; base_url + ; fullscreen + ; default_sizes + ; functions + ; snapshot_directory + ; diff_pixel_color + ; parallelism + }) + ;; +end + +module JSON = struct + let ( let* ) = Result.bind + + let parse path = + let debug = OSnap_Logger.debug ~header:"Config.Global.JSON.parse" in + let config = OSnap_Utils.get_file_contents path in + let json = config |> Yojson.Basic.from_string ~fname:path in + let* base_url = + try + json + |> Yojson.Basic.Util.member "baseUrl" + |> Yojson.Basic.Util.to_string + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + debug (Printf.sprintf "baseUrl is set to %S" base_url); + let* fullscreen = + try + json + |> Yojson.Basic.Util.member "fullScreen" + |> Yojson.Basic.Util.to_bool_option + |> Option.value ~default:false + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + debug (Printf.sprintf "fullScreen is set to %b" fullscreen); + let* threshold = + try + json + |> Yojson.Basic.Util.member "threshold" + |> Yojson.Basic.Util.to_int_option + |> Option.value ~default:0 + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + debug (Printf.sprintf "threshold is set to %i" threshold); + let* default_sizes = + try + json + |> Yojson.Basic.Util.member "defaultSizes" + |> Yojson.Basic.Util.to_list + |> OSnap_Utils.List.map_until_exception OSnap_Config_Utils.JSON.parse_size + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + let* functions = + json + |> Yojson.Basic.Util.member "functions" + |> function + | `Null -> Result.ok [] + | `Assoc assoc -> + assoc + |> OSnap_Utils.List.map_until_exception (fun (key, actions) -> + let* actions = + try + actions + |> Yojson.Basic.Util.to_list + |> OSnap_Utils.List.map_until_exception + OSnap_Config_Utils.JSON.parse_action + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + Result.ok (key, actions)) + | _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ("The functions option has to be an object.", Some path)) + in + let* snapshot_directory = + try + json + |> Yojson.Basic.Util.member "snapshotDirectory" + |> Yojson.Basic.Util.to_string_option + |> Option.value ~default:"__snapshots__" + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + debug (Printf.sprintf "snapshot directory is set to %s" snapshot_directory); + let* parallelism = + try + json + |> Yojson.Basic.Util.member "parallelism" + |> Yojson.Basic.Util.to_int_option + |> Option.value ~default:8 + |> max 1 + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + debug (Printf.sprintf "parallelism is set to %i" parallelism); + let root_path = + String.sub path 0 (String.length path - String.length "osnap.config.json") + in + debug (Printf.sprintf "setting root path to %s" root_path); + let* ignore_patterns = + try + json + |> Yojson.Basic.Util.member "ignorePatterns" + |> function + | `List list -> + list + |> OSnap_Utils.List.map_until_exception (fun item -> + try Yojson.Basic.Util.to_string item |> Result.ok with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path))) + | _ -> Result.ok [ "**/node_modules/**" ] + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + debug + (Printf.sprintf + "ignore_patterns are set to %s" + (List.fold_left (fun curr acc -> acc ^ " " ^ curr) "" ignore_patterns)); + let* test_pattern = + try + json + |> Yojson.Basic.Util.member "testPattern" + |> Yojson.Basic.Util.to_string_option + |> Option.value ~default:"**/*.osnap.json" + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, Some path)) + in + debug (Printf.sprintf "test pattern is set to %s" test_pattern); + let* diff_pixel_color = + json + |> Yojson.Basic.Util.member "diffPixelColor" + |> function + | `Assoc _ as assoc -> + let get_color = function + | `Int i -> Result.ok i + | `Float f -> Result.ok (int_of_float f) + | _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ("diffPixelColor does not have a correct format", Some path)) + in + let* r = assoc |> Yojson.Basic.Util.member "r" |> get_color in + let* g = assoc |> Yojson.Basic.Util.member "g" |> get_color in + let* b = assoc |> Yojson.Basic.Util.member "b" |> get_color in + Result.ok (r, g, b) + | `Null -> Result.ok (255, 0, 0) + | _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ("diffPixelColor does not have a correct format", Some path)) + in + let r, g, b = diff_pixel_color in + debug (Printf.sprintf "diff pixel color is set to %i,%i,%i" r g b); + debug "looking for duplicate names in defined sizes"; + let duplicates = + default_sizes + |> List.filter (fun (s : OSnap_Config_Types.size) -> Option.is_some s.name) + |> OSnap_Utils.find_duplicates (fun (s : OSnap_Config_Types.size) -> s.name) + |> List.map (fun (s : OSnap_Config_Types.size) -> + let name = Option.value s.name ~default:"" in + debug (Printf.sprintf "found size with duplicate name %S" name); + name) + in + if List.length duplicates <> 0 + then Result.error (OSnap_Response.Config_Duplicate_Size_Names duplicates) + else ( + debug "did not find duplicates"; + Result.ok + { root_path + ; threshold + ; test_pattern + ; ignore_patterns + ; base_url + ; fullscreen + ; default_sizes + ; functions + ; snapshot_directory + ; diff_pixel_color + ; parallelism + }) + ;; +end + +let find config_names = + let debug = OSnap_Logger.debug ~header:"Config.Global.find" in + let rec scan_dir ~config_names segments = + let current_path = segments |> OSnap_Utils.path_of_segments in + let elements = current_path |> Sys.readdir |> Array.to_list in + debug + (Printf.sprintf + "looking for %S in %S" + (String.concat "," config_names) + current_path); + let files = + elements + |> List.find_all (fun el -> + let path = OSnap_Utils.path_of_segments (el :: segments) in + let is_direcoty = path |> Sys.is_directory in + not is_direcoty) + in + debug (Printf.sprintf "found %i files in this directory" (List.length files)); + let found_file = files |> List.find_opt (fun file -> List.mem file config_names) in + match found_file with + | Some file -> Some (file, segments) + | None -> + let parent_dir_segments = ".." :: segments in + let parent_dir = parent_dir_segments |> OSnap_Utils.path_of_segments in + debug "did not find a config file in this directory"; + (try + if parent_dir |> Sys.is_directory + then ( + debug "looking in parent directory"; + scan_dir ~config_names parent_dir_segments) + else ( + debug "there is no parent directory anymore"; + None) + with + | Sys_error _ -> None) + in + let base_path = Sys.getcwd () in + let config_path = scan_dir ~config_names [ base_path ] in + match config_path with + | None -> + debug "no config file was found"; + None + | Some (file, segments) -> + let path = file :: segments |> OSnap_Utils.path_of_segments in + debug (Printf.sprintf "found config file at %S" path); + Some path +;; + +let init ~config_path = + let ( let* ) = Result.bind in + let debug = OSnap_Logger.debug ~header:"Config.Global.init" in + let* config = + if config_path = "" + then ( + debug "looking for global config file"; + match find [ "osnap.config.json"; "osnap.config.yaml" ] with + | Some path -> Result.ok path + | None -> Result.error OSnap_Response.Config_Global_Not_Found) + else ( + debug (Printf.sprintf "using provided config path %S" config_path); + Result.ok config_path) + in + debug ("found global config file at " ^ config); + debug "parsing config file"; + let* format = OSnap_Config_Utils.get_format config in + match format with + | OSnap_Config_Types.JSON -> JSON.parse config + | OSnap_Config_Types.YAML -> YAML.parse config +;; diff --git a/lib/OSnap_Config/OSnap_Config_Global.mli b/lib/OSnap_Config/OSnap_Config_Global.mli new file mode 100644 index 0000000..a5be930 --- /dev/null +++ b/lib/OSnap_Config/OSnap_Config_Global.mli @@ -0,0 +1 @@ +val init : config_path:string -> (OSnap_Config_Types.global, OSnap_Response.t) result diff --git a/lib/OSnap_Config/OSnap_Config_Global.re b/lib/OSnap_Config/OSnap_Config_Global.re deleted file mode 100644 index f27bda9..0000000 --- a/lib/OSnap_Config/OSnap_Config_Global.re +++ /dev/null @@ -1,542 +0,0 @@ -open OSnap_Config_Types; - -module YAML = { - let ( let* ) = Result.bind; - let parse = path => { - let debug = OSnap_Logger.debug(~header="Config.Global.YAML.parse"); - - let config = OSnap_Utils.get_file_contents(path); - - let* yaml = - config - |> Yaml.of_string - |> Result.map_error(_ => - OSnap_Response.Config_Parse_Error( - Printf.sprintf("YAML could not be parsed"), - Some(path), - ) - ); - - let* base_url = yaml |> OSnap_Config_Utils.YAML.get_string("baseUrl"); - debug(Printf.sprintf("baseUrl is set to %S", base_url)); - - let* fullscreen = - yaml - |> OSnap_Config_Utils.YAML.get_bool_option("fullScreen") - |> Result.map(Option.value(~default=false)); - debug(Printf.sprintf("fullScreen is set to %b", fullscreen)); - - let* threshold = - yaml - |> OSnap_Config_Utils.YAML.get_int_option("threshold") - |> Result.map(Option.value(~default=0)); - debug(Printf.sprintf("threshold is set to %i", threshold)); - - let* ignore_patterns = - yaml - |> OSnap_Config_Utils.YAML.get_string_list_option("ignorePatterns") - |> Result.map(Option.value(~default=["**/node_modules/**"])); - debug( - Printf.sprintf( - "ignore_patterns are set to %s", - String.concat(",", ignore_patterns), - ), - ); - - let* default_sizes = - yaml - |> OSnap_Config_Utils.YAML.get_list_option( - "defaultSizes", - ~parser=OSnap_Config_Utils.YAML.parse_size, - ) - |> Result.map(Option.value(~default=[])); - - let* functions = { - let f = yaml |> Yaml.Util.find_exn("functions"); - f - |> Option.map(f => { - f - |> Yaml.Util.keys_exn - |> OSnap_Utils.List.map_until_exception(key => { - let* actions = - f - |> OSnap_Config_Utils.YAML.get_list_option( - key, - ~parser=OSnap_Config_Utils.YAML.parse_action, - ) - |> Result.map(Option.value(~default=[])); - (key, actions) |> Result.ok; - }) - }) - |> Option.value(~default=Result.ok([])); - }; - - let* snapshot_directory = - yaml - |> OSnap_Config_Utils.YAML.get_string_option("snapshotDirectory") - |> Result.map(Option.value(~default="__snapshots__")); - debug( - Printf.sprintf("snapshot directory is set to %s", snapshot_directory), - ); - - let* parallelism = - yaml - |> OSnap_Config_Utils.YAML.get_int_option("parallelism") - |> Result.map(Option.value(~default=8)) - |> Result.map(max(1)); - debug(Printf.sprintf("parallelism is set to %i", parallelism)); - - let root_path = - String.sub( - path, - 0, - String.length(path) - String.length("osnap.config.yaml"), - ); - - debug(Printf.sprintf("setting root path to %s", root_path)); - - let* test_pattern = - yaml - |> OSnap_Config_Utils.YAML.get_string_option("testPattern") - |> Result.map(Option.value(~default="**/*.osnap.yaml")); - debug(Printf.sprintf("test pattern is set to %s", test_pattern)); - - let* diff_pixel_color = - yaml - |> Yaml.Util.find("diffPixelColor") - |> Result.map_error( - fun - | `Msg(message) => - OSnap_Response.Config_Parse_Error(message, Some(path)), - ) - |> Result.map( - Option.map(colors => { - let get_color = - fun - | `Float(f) => Result.ok(int_of_float(f)) - | `String(s) => Result.ok(int_of_string(s)) - | _ => - Result.error( - OSnap_Response.Config_Parse_Error( - "diffPixelColor does not have a correct format", - Some(path), - ), - ); - - let* r = - colors - |> Yaml.Util.find("r") - |> Result.map_error( - fun - | `Msg(message) => - OSnap_Response.Config_Parse_Error(message, Some(path)), - ) - |> Result.map(Option.map(get_color)) - |> Result.map(OSnap_Config_Utils.to_result_option) - |> Result.join - |> Result.map(Option.value(~default=255)); - - let* g = - colors - |> Yaml.Util.find("g") - |> Result.map_error( - fun - | `Msg(message) => - OSnap_Response.Config_Parse_Error(message, Some(path)), - ) - |> Result.map(Option.map(get_color)) - |> Result.map(OSnap_Config_Utils.to_result_option) - |> Result.join - |> Result.map(Option.value(~default=0)); - - let* b = - colors - |> Yaml.Util.find("b") - |> Result.map_error( - fun - | `Msg(message) => - OSnap_Response.Config_Parse_Error(message, Some(path)), - ) - |> Result.map(Option.map(get_color)) - |> Result.map(OSnap_Config_Utils.to_result_option) - |> Result.join - |> Result.map(Option.value(~default=0)); - - Result.ok((r, g, b)); - }), - ) - |> Result.map(OSnap_Config_Utils.to_result_option) - |> Result.join - |> Result.map(Option.value(~default=(255, 0, 0))); - - let (r, g, b) = diff_pixel_color; - debug(Printf.sprintf("diff pixel color is set to %i,%i,%i", r, g, b)); - - debug("looking for duplicate names in defined sizes"); - let duplicates = - default_sizes - |> List.filter((s: OSnap_Config_Types.size) => Option.is_some(s.name)) - |> OSnap_Utils.find_duplicates((s: OSnap_Config_Types.size) => s.name) - |> List.map((s: OSnap_Config_Types.size) => { - let name = Option.value(s.name, ~default=""); - debug(Printf.sprintf("found size with duplicate name %S", name)); - name; - }); - - if (List.length(duplicates) != 0) { - Result.error(OSnap_Response.Config_Duplicate_Size_Names(duplicates)); - } else { - debug("did not find duplicates"); - Result.ok({ - root_path, - threshold, - test_pattern, - ignore_patterns, - base_url, - fullscreen, - default_sizes, - functions, - snapshot_directory, - diff_pixel_color, - parallelism, - }); - }; - }; -}; - -module JSON = { - let ( let* ) = Result.bind; - let parse = path => { - let debug = OSnap_Logger.debug(~header="Config.Global.JSON.parse"); - - let config = OSnap_Utils.get_file_contents(path); - let json = config |> Yojson.Basic.from_string(~fname=path); - - let* base_url = - try( - json - |> Yojson.Basic.Util.member("baseUrl") - |> Yojson.Basic.Util.to_string - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, Some(path))) - }; - debug(Printf.sprintf("baseUrl is set to %S", base_url)); - - let* fullscreen = - try( - json - |> Yojson.Basic.Util.member("fullScreen") - |> Yojson.Basic.Util.to_bool_option - |> Option.value(~default=false) - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, Some(path))) - }; - debug(Printf.sprintf("fullScreen is set to %b", fullscreen)); - - let* threshold = - try( - json - |> Yojson.Basic.Util.member("threshold") - |> Yojson.Basic.Util.to_int_option - |> Option.value(~default=0) - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, Some(path))) - }; - debug(Printf.sprintf("threshold is set to %i", threshold)); - - let* default_sizes = - try( - json - |> Yojson.Basic.Util.member("defaultSizes") - |> Yojson.Basic.Util.to_list - |> OSnap_Utils.List.map_until_exception( - OSnap_Config_Utils.JSON.parse_size, - ) - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, Some(path))) - }; - - let* functions = { - json - |> Yojson.Basic.Util.member("functions") - |> ( - fun - | `Null => Result.ok([]) - | `Assoc(assoc) => - assoc - |> OSnap_Utils.List.map_until_exception(((key, actions)) => { - let* actions = - try( - actions - |> Yojson.Basic.Util.to_list - |> OSnap_Utils.List.map_until_exception( - OSnap_Config_Utils.JSON.parse_action, - ) - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error( - OSnap_Response.Config_Parse_Error(message, Some(path)), - ) - }; - Result.ok((key, actions)); - }) - | _ => - Result.error( - OSnap_Response.Config_Parse_Error( - "The functions option has to be an object.", - Some(path), - ), - ) - ); - }; - - let* snapshot_directory = - try( - json - |> Yojson.Basic.Util.member("snapshotDirectory") - |> Yojson.Basic.Util.to_string_option - |> Option.value(~default="__snapshots__") - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, Some(path))) - }; - debug( - Printf.sprintf("snapshot directory is set to %s", snapshot_directory), - ); - - let* parallelism = - try( - json - |> Yojson.Basic.Util.member("parallelism") - |> Yojson.Basic.Util.to_int_option - |> Option.value(~default=8) - |> max(1) - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, Some(path))) - }; - debug(Printf.sprintf("parallelism is set to %i", parallelism)); - - let root_path = - String.sub( - path, - 0, - String.length(path) - String.length("osnap.config.json"), - ); - - debug(Printf.sprintf("setting root path to %s", root_path)); - - let* ignore_patterns = - try( - json - |> Yojson.Basic.Util.member("ignorePatterns") - |> ( - fun - | `List(list) => - list - |> OSnap_Utils.List.map_until_exception(item => - try(Yojson.Basic.Util.to_string(item) |> Result.ok) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error( - OSnap_Response.Config_Parse_Error(message, Some(path)), - ) - } - ) - | _ => Result.ok(["**/node_modules/**"]) - ) - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, Some(path))) - }; - - debug( - Printf.sprintf( - "ignore_patterns are set to %s", - List.fold_left( - (curr, acc) => acc ++ " " ++ curr, - "", - ignore_patterns, - ), - ), - ); - - let* test_pattern = - try( - json - |> Yojson.Basic.Util.member("testPattern") - |> Yojson.Basic.Util.to_string_option - |> Option.value(~default="**/*.osnap.json") - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, Some(path))) - }; - debug(Printf.sprintf("test pattern is set to %s", test_pattern)); - - let* diff_pixel_color = - json - |> Yojson.Basic.Util.member("diffPixelColor") - |> ( - fun - | `Assoc(_) as assoc => { - let get_color = ( - fun - | `Int(i) => Result.ok(i) - | `Float(f) => Result.ok(int_of_float(f)) - | _ => - Result.error( - OSnap_Response.Config_Parse_Error( - "diffPixelColor does not have a correct format", - Some(path), - ), - ) - ); - - let* r = assoc |> Yojson.Basic.Util.member("r") |> get_color; - let* g = assoc |> Yojson.Basic.Util.member("g") |> get_color; - let* b = assoc |> Yojson.Basic.Util.member("b") |> get_color; - Result.ok((r, g, b)); - } - | `Null => Result.ok((255, 0, 0)) - | _ => - Result.error( - OSnap_Response.Config_Parse_Error( - "diffPixelColor does not have a correct format", - Some(path), - ), - ) - ); - - let (r, g, b) = diff_pixel_color; - debug(Printf.sprintf("diff pixel color is set to %i,%i,%i", r, g, b)); - - debug("looking for duplicate names in defined sizes"); - let duplicates = - default_sizes - |> List.filter((s: OSnap_Config_Types.size) => Option.is_some(s.name)) - |> OSnap_Utils.find_duplicates((s: OSnap_Config_Types.size) => s.name) - |> List.map((s: OSnap_Config_Types.size) => { - let name = Option.value(s.name, ~default=""); - debug(Printf.sprintf("found size with duplicate name %S", name)); - name; - }); - - if (List.length(duplicates) != 0) { - Result.error(OSnap_Response.Config_Duplicate_Size_Names(duplicates)); - } else { - debug("did not find duplicates"); - Result.ok({ - root_path, - threshold, - test_pattern, - ignore_patterns, - base_url, - fullscreen, - default_sizes, - functions, - snapshot_directory, - diff_pixel_color, - parallelism, - }); - }; - }; -}; - -let find = config_names => { - let debug = OSnap_Logger.debug(~header="Config.Global.find"); - - let rec scan_dir = (~config_names, segments) => { - let current_path = segments |> OSnap_Utils.path_of_segments; - let elements = current_path |> Sys.readdir |> Array.to_list; - - debug( - Printf.sprintf( - "looking for %S in %S", - String.concat(",", config_names), - current_path, - ), - ); - - let files = - elements - |> List.find_all(el => { - let path = OSnap_Utils.path_of_segments([el, ...segments]); - let is_direcoty = path |> Sys.is_directory; - !is_direcoty; - }); - - debug( - Printf.sprintf("found %i files in this directory", List.length(files)), - ); - let found_file = - files |> List.find_opt(file => List.mem(file, config_names)); - switch (found_file) { - | Some(file) => Some((file, segments)) - | None => - let parent_dir_segments = ["..", ...segments]; - let parent_dir = parent_dir_segments |> OSnap_Utils.path_of_segments; - - debug("did not find a config file in this directory"); - - try( - if (parent_dir |> Sys.is_directory) { - debug("looking in parent directory"); - scan_dir(~config_names, parent_dir_segments); - } else { - debug("there is no parent directory anymore"); - None; - } - ) { - | Sys_error(_) => None - }; - }; - }; - - let base_path = Sys.getcwd(); - let config_path = scan_dir(~config_names, [base_path]); - - switch (config_path) { - | None => - debug("no config file was found"); - None; - | Some((file, segments)) => - let path = [file, ...segments] |> OSnap_Utils.path_of_segments; - debug(Printf.sprintf("found config file at %S", path)); - Some(path); - }; -}; - -let init = (~config_path) => { - let ( let* ) = Result.bind; - - let debug = OSnap_Logger.debug(~header="Config.Global.init"); - let* config = - if (config_path == "") { - debug("looking for global config file"); - switch (find(["osnap.config.json", "osnap.config.yaml"])) { - | Some(path) => Result.ok(path) - | None => Result.error(OSnap_Response.Config_Global_Not_Found) - }; - } else { - debug(Printf.sprintf("using provided config path %S", config_path)); - Result.ok(config_path); - }; - - debug("found global config file at " ++ config); - debug("parsing config file"); - let* format = OSnap_Config_Utils.get_format(config); - - switch (format) { - | OSnap_Config_Types.JSON => JSON.parse(config) - | OSnap_Config_Types.YAML => YAML.parse(config) - }; -}; diff --git a/lib/OSnap_Config/OSnap_Config_Global.rei b/lib/OSnap_Config/OSnap_Config_Global.rei deleted file mode 100644 index 0bb2bfc..0000000 --- a/lib/OSnap_Config/OSnap_Config_Global.rei +++ /dev/null @@ -1,3 +0,0 @@ -let init: - (~config_path: string) => - result(OSnap_Config_Types.global, OSnap_Response.t); diff --git a/lib/OSnap_Config/OSnap_Config_Test.ml b/lib/OSnap_Config/OSnap_Config_Test.ml new file mode 100644 index 0000000..47830fd --- /dev/null +++ b/lib/OSnap_Config/OSnap_Config_Test.ml @@ -0,0 +1,386 @@ +open OSnap_Config_Types + +let ( let* ) = Result.bind + +module Common = struct + let collect_duplicates ~debug sizes = + debug "looking for duplicate names in defined sizes"; + let duplicates = + sizes + |> List.filter (fun (s : OSnap_Config_Types.size) -> Option.is_some s.name) + |> OSnap_Utils.find_duplicates (fun (s : OSnap_Config_Types.size) -> s.name) + |> List.map (fun (s : OSnap_Config_Types.size) -> + let name = Option.value s.name ~default:"" in + debug (Printf.sprintf "found size with duplicate name %S" name); + name) + in + if List.length duplicates <> 0 + then Result.error (OSnap_Response.Config_Duplicate_Size_Names duplicates) + else ( + debug "did not find duplicates"; + Result.ok ()) + ;; + + let collect_ignore ~debug ~size_restriction ~selector ~selector_all ~x1 ~y1 ~x2 ~y2 = + match selector_all, selector, x1, y1, x2, y2 with + | Some selector_all, None, None, None, None, None -> + debug (Printf.sprintf "using selectorAll %S" selector_all); + SelectorAll (selector_all, size_restriction) |> Result.ok + | None, Some selector, None, None, None, None -> + debug (Printf.sprintf "using selector %S" selector); + Selector (selector, size_restriction) |> Result.ok + | None, None, Some x1, Some y1, Some x2, Some y2 -> + debug (Printf.sprintf "using coordinates (%i,%i),(%i,%i)" x1 y1 x2 y2); + Coordinates ((x1, y1), (x2, y2), size_restriction) |> Result.ok + | _ -> + Result.error + (OSnap_Response.Config_Invalid + ("Did not find a complete configuration for an ignore region.", None)) + ;; +end + +module JSON = struct + let parse_ignore r = + let debug = OSnap_Logger.debug ~header:"Config.Test.parse_ignore" in + let* size_restriction = + try + r + |> Yojson.Basic.Util.member "@" + |> Yojson.Basic.Util.to_option Yojson.Basic.Util.to_list + |> Option.map (List.map Yojson.Basic.Util.to_string) + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* x1 = + try + r |> Yojson.Basic.Util.member "x1" |> Yojson.Basic.Util.to_int_option |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* y1 = + try + r |> Yojson.Basic.Util.member "y1" |> Yojson.Basic.Util.to_int_option |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* x2 = + try + r |> Yojson.Basic.Util.member "x2" |> Yojson.Basic.Util.to_int_option |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* y2 = + try + r |> Yojson.Basic.Util.member "y2" |> Yojson.Basic.Util.to_int_option |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* selector = + try + r + |> Yojson.Basic.Util.member "selector" + |> Yojson.Basic.Util.to_string_option + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* selector_all = + try + r + |> Yojson.Basic.Util.member "selectorAll" + |> Yojson.Basic.Util.to_string_option + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + Common.collect_ignore ~debug ~size_restriction ~selector ~selector_all ~x1 ~y1 ~x2 ~y2 + ;; + + let parse_single_test (global_config : OSnap_Config_Types.global) test = + let debug = OSnap_Logger.debug ~header:"Config.Test.parse" in + let* name = + try + test + |> Yojson.Basic.Util.member "name" + |> Yojson.Basic.Util.to_string + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + debug (Printf.sprintf "name: %S" name); + let* only = + try + test + |> Yojson.Basic.Util.member "only" + |> Yojson.Basic.Util.to_bool_option + |> Option.value ~default:false + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + debug (Printf.sprintf "only: %b" only); + let* skip = + try + test + |> Yojson.Basic.Util.member "skip" + |> Yojson.Basic.Util.to_bool_option + |> Option.value ~default:false + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + debug (Printf.sprintf "skip: %b" only); + let* threshold = + try + test + |> Yojson.Basic.Util.member "threshold" + |> Yojson.Basic.Util.to_int_option + |> Option.value ~default:global_config.threshold + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + debug (Printf.sprintf "threshold: %i" threshold); + let* url = + try + test |> Yojson.Basic.Util.member "url" |> Yojson.Basic.Util.to_string |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + debug (Printf.sprintf "url: %s" url); + let* sizes = + test + |> Yojson.Basic.Util.member "sizes" + |> function + | `Null -> + debug "no sizes present. using default sizes"; + Result.ok global_config.default_sizes + | `List list -> + debug "parsing sizes"; + list |> OSnap_Utils.List.map_until_exception OSnap_Config_Utils.JSON.parse_size + | _ -> + Result.error + (OSnap_Response.Config_Invalid ("sizes has an invalid format.", None)) + in + let* actions = + test + |> Yojson.Basic.Util.member "actions" + |> function + | `List list -> + debug "parsing actions"; + OSnap_Utils.List.map_until_exception OSnap_Config_Utils.JSON.parse_action list + | _ -> Result.ok [] + in + let* ignore = + test + |> Yojson.Basic.Util.member "ignore" + |> function + | `List list -> + debug "parsing ignore regions"; + OSnap_Utils.List.map_until_exception parse_ignore list + | _ -> Result.ok [] + in + let* () = Common.collect_duplicates ~debug sizes in + Result.ok { only; skip; threshold; name; url; sizes; actions; ignore } + ;; + + let parse global_config path = + let debug = OSnap_Logger.debug ~header:"Config.Test.parse" in + let config = OSnap_Utils.get_file_contents path in + debug (Printf.sprintf "parsing test file %S" path); + let json = config |> Yojson.Basic.from_string ~fname:path in + try + json + |> Yojson.Basic.Util.to_list + |> OSnap_Utils.List.map_until_exception (parse_single_test global_config) + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + ;; +end + +module YAML = struct + let parse_ignore r = + let debug = OSnap_Logger.debug ~header:"Config.Test.YAML.parse_ignore" in + let* size_restriction = r |> OSnap_Config_Utils.YAML.get_string_list_option "@" in + let* x1 = r |> OSnap_Config_Utils.YAML.get_int_option "x1" in + let* y1 = r |> OSnap_Config_Utils.YAML.get_int_option "y1" in + let* x2 = r |> OSnap_Config_Utils.YAML.get_int_option "x2" in + let* y2 = r |> OSnap_Config_Utils.YAML.get_int_option "y2" in + let* selector = r |> OSnap_Config_Utils.YAML.get_string_option "selector" in + let* selector_all = r |> OSnap_Config_Utils.YAML.get_string_option "selectorAll" in + Common.collect_ignore ~debug ~size_restriction ~selector ~selector_all ~x1 ~y1 ~x2 ~y2 + ;; + + let parse_single_test (global_config : OSnap_Config_Types.global) test = + let debug = OSnap_Logger.debug ~header:"Config.Test.YAML.parse" in + let* name = test |> OSnap_Config_Utils.YAML.get_string "name" in + debug (Printf.sprintf "name: %S" name); + let* url = test |> OSnap_Config_Utils.YAML.get_string "url" in + debug (Printf.sprintf "url: %s" url); + let* only = + test + |> OSnap_Config_Utils.YAML.get_bool_option "only" + |> Result.map (Option.value ~default:false) + in + debug (Printf.sprintf "only: %b" only); + let* skip = + test + |> OSnap_Config_Utils.YAML.get_bool_option "skip" + |> Result.map (Option.value ~default:false) + in + debug (Printf.sprintf "skip: %b" only); + let* threshold = + test + |> OSnap_Config_Utils.YAML.get_int_option "threshold" + |> Result.map (Option.value ~default:global_config.threshold) + in + debug (Printf.sprintf "threshold: %i" threshold); + let* sizes = + test + |> OSnap_Config_Utils.YAML.get_list_option + "sizes" + ~parser:OSnap_Config_Utils.YAML.parse_size + |> Result.map (Option.value ~default:global_config.default_sizes) + in + let* actions = + test + |> OSnap_Config_Utils.YAML.get_list_option + "actions" + ~parser:OSnap_Config_Utils.YAML.parse_action + |> Result.map (Option.value ~default:[]) + in + let* ignore = + test + |> OSnap_Config_Utils.YAML.get_list_option "ignore" ~parser:parse_ignore + |> Result.map (Option.value ~default:[]) + in + let* () = Common.collect_duplicates ~debug sizes in + Result.ok { only; skip; threshold; name; url; sizes; actions; ignore } + ;; + + let parse global_config path = + let debug = OSnap_Logger.debug ~header:"Config.Test.YAML.parse" in + let config = OSnap_Utils.get_file_contents path in + debug (Printf.sprintf "parsing test file %S" path); + let* yaml = + config + |> Yaml.of_string + |> Result.map_error (fun _ -> + OSnap_Response.Config_Parse_Error + (Printf.sprintf "YAML could not be parsed", Some path)) + in + yaml + |> (function + | `A lst -> Result.ok lst + | _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ("A test file has to be an array of tests.", Some path))) + |> Result.map (OSnap_Utils.List.map_until_exception (parse_single_test global_config)) + |> Result.join + |> Result.map_error (fun err -> + match err with + | OSnap_Response.Config_Parse_Error (err, None) -> + OSnap_Response.Config_Parse_Error (err, Some path) + | OSnap_Response.Config_Parse_Error (err, Some path) -> + OSnap_Response.Config_Parse_Error (err, Some path) + | OSnap_Response.Config_Global_Not_Found -> + OSnap_Response.Config_Global_Not_Found + | OSnap_Response.Config_Unsupported_Format f -> + OSnap_Response.Config_Unsupported_Format f + | OSnap_Response.Config_Invalid (msg, None) -> + OSnap_Response.Config_Invalid (msg, Some path) + | OSnap_Response.Config_Invalid (msg, Some path) -> + OSnap_Response.Config_Invalid (msg, Some path) + | OSnap_Response.Config_Duplicate_Tests t -> + OSnap_Response.Config_Duplicate_Tests t + | OSnap_Response.Config_Duplicate_Size_Names n -> + OSnap_Response.Config_Duplicate_Size_Names n + | OSnap_Response.CDP_Protocol_Error e -> OSnap_Response.CDP_Protocol_Error e + | OSnap_Response.CDP_Connection_Failed -> OSnap_Response.CDP_Connection_Failed + | OSnap_Response.Invalid_Run s -> OSnap_Response.Invalid_Run s + | OSnap_Response.FS_Error e -> OSnap_Response.FS_Error e + | OSnap_Response.Test_Failure -> OSnap_Response.Test_Failure + | OSnap_Response.Unknown_Error e -> OSnap_Response.Unknown_Error e) + ;; +end + +let find ?(root_path = "/") ?(pattern = "**/*.osnap.json") ?(ignore_patterns = []) () = + let debug = OSnap_Logger.debug ~header:"Config.Test.find" in + debug (Printf.sprintf "looking for test files matching %S" pattern); + let pattern = pattern |> Re.Glob.glob |> Re.compile in + let ignore_patterns = + ignore_patterns + |> List.map (fun pattern -> + debug (Printf.sprintf "adding %S to ignore patterns" pattern); + pattern |> Re.Glob.glob |> Re.compile) + in + let is_ignored path = + let ignored = ignore_patterns |> List.exists (fun __x -> Re.execp __x path) in + if ignored then debug (Printf.sprintf "ignoring %S" path); + ignored + in + FileUtil.find + (Custom + (fun path -> + if not (is_ignored path) + then ( + let matches = Re.execp pattern path in + debug (Printf.sprintf "checking: %S" path); + if matches then debug (Printf.sprintf "matched: %S" path); + matches) + else false)) + root_path + (fun acc curr -> curr :: acc) + [] + |> OSnap_Utils.List.map_until_exception (fun path -> + let* format = OSnap_Config_Utils.get_format path in + Result.ok (path, format)) +;; + +let init config = + let debug = OSnap_Logger.debug ~header:"Config.Test.init" in + debug "looking for test files"; + let* tests = + find + ~root_path:config.root_path + ~pattern:config.test_pattern + ~ignore_patterns:config.ignore_patterns + () + |> (fun __x -> + Result.bind + __x + (OSnap_Utils.List.map_until_exception (fun (path, test_format) -> + match test_format with + | OSnap_Config_Types.JSON -> JSON.parse config path + | OSnap_Config_Types.YAML -> YAML.parse config path))) + |> Result.map List.flatten + in + debug "looking for duplicate names in test files"; + let duplicates = + tests + |> OSnap_Utils.find_duplicates (fun (t : OSnap_Config_Types.test) -> t.name) + |> List.map (fun (t : OSnap_Config_Types.test) -> + debug (Printf.sprintf "found test with duplicate name %S" t.name); + t.name) + in + if List.length duplicates <> 0 + then Result.error (OSnap_Response.Config_Duplicate_Tests duplicates) + else ( + debug "did not find duplicates"; + Result.ok tests) +;; diff --git a/lib/OSnap_Config/OSnap_Config_Test.mli b/lib/OSnap_Config/OSnap_Config_Test.mli new file mode 100644 index 0000000..34ecf7e --- /dev/null +++ b/lib/OSnap_Config/OSnap_Config_Test.mli @@ -0,0 +1,3 @@ +val init + : OSnap_Config_Types.global + -> (OSnap_Config_Types.test list, OSnap_Response.t) result diff --git a/lib/OSnap_Config/OSnap_Config_Test.re b/lib/OSnap_Config/OSnap_Config_Test.re deleted file mode 100644 index c511ef7..0000000 --- a/lib/OSnap_Config/OSnap_Config_Test.re +++ /dev/null @@ -1,534 +0,0 @@ -open OSnap_Config_Types; - -let ( let* ) = Result.bind; - -module Common = { - let collect_duplicates = (~debug, sizes) => { - debug("looking for duplicate names in defined sizes"); - let duplicates = - sizes - |> List.filter((s: OSnap_Config_Types.size) => Option.is_some(s.name)) - |> OSnap_Utils.find_duplicates((s: OSnap_Config_Types.size) => s.name) - |> List.map((s: OSnap_Config_Types.size) => { - let name = Option.value(s.name, ~default=""); - debug(Printf.sprintf("found size with duplicate name %S", name)); - name; - }); - - if (List.length(duplicates) != 0) { - Result.error(OSnap_Response.Config_Duplicate_Size_Names(duplicates)); - } else { - debug("did not find duplicates"); - Result.ok(); - }; - }; - - let collect_ignore = - ( - ~debug, - ~size_restriction, - ~selector, - ~selector_all, - ~x1, - ~y1, - ~x2, - ~y2, - ) => { - switch (selector_all, selector, x1, y1, x2, y2) { - | (Some(selector_all), None, None, None, None, None) => - debug(Printf.sprintf("using selectorAll %S", selector_all)); - SelectorAll(selector_all, size_restriction) |> Result.ok; - | (None, Some(selector), None, None, None, None) => - debug(Printf.sprintf("using selector %S", selector)); - Selector(selector, size_restriction) |> Result.ok; - | (None, None, Some(x1), Some(y1), Some(x2), Some(y2)) => - debug( - Printf.sprintf("using coordinates (%i,%i),(%i,%i)", x1, y1, x2, y2), - ); - Coordinates((x1, y1), (x2, y2), size_restriction) |> Result.ok; - | _ => - Result.error( - OSnap_Response.Config_Invalid( - "Did not find a complete configuration for an ignore region.", - None, - ), - ) - }; - }; -}; - -module JSON = { - let parse_ignore = r => { - let debug = OSnap_Logger.debug(~header="Config.Test.parse_ignore"); - - let* size_restriction = - try( - r - |> Yojson.Basic.Util.member("@") - |> Yojson.Basic.Util.to_option(Yojson.Basic.Util.to_list) - |> Option.map(List.map(Yojson.Basic.Util.to_string)) - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* x1 = - try( - r - |> Yojson.Basic.Util.member("x1") - |> Yojson.Basic.Util.to_int_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* y1 = - try( - r - |> Yojson.Basic.Util.member("y1") - |> Yojson.Basic.Util.to_int_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* x2 = - try( - r - |> Yojson.Basic.Util.member("x2") - |> Yojson.Basic.Util.to_int_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* y2 = - try( - r - |> Yojson.Basic.Util.member("y2") - |> Yojson.Basic.Util.to_int_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* selector = - try( - r - |> Yojson.Basic.Util.member("selector") - |> Yojson.Basic.Util.to_string_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* selector_all = - try( - r - |> Yojson.Basic.Util.member("selectorAll") - |> Yojson.Basic.Util.to_string_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - Common.collect_ignore( - ~debug, - ~size_restriction, - ~selector, - ~selector_all, - ~x1, - ~y1, - ~x2, - ~y2, - ); - }; - - let parse_single_test = (global_config: OSnap_Config_Types.global, test) => { - let debug = OSnap_Logger.debug(~header="Config.Test.parse"); - - let* name = - try( - test - |> Yojson.Basic.Util.member("name") - |> Yojson.Basic.Util.to_string - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - debug(Printf.sprintf("name: %S", name)); - - let* only = - try( - test - |> Yojson.Basic.Util.member("only") - |> Yojson.Basic.Util.to_bool_option - |> Option.value(~default=false) - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - debug(Printf.sprintf("only: %b", only)); - - let* skip = - try( - test - |> Yojson.Basic.Util.member("skip") - |> Yojson.Basic.Util.to_bool_option - |> Option.value(~default=false) - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - debug(Printf.sprintf("skip: %b", only)); - - let* threshold = - try( - test - |> Yojson.Basic.Util.member("threshold") - |> Yojson.Basic.Util.to_int_option - |> Option.value(~default=global_config.threshold) - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - debug(Printf.sprintf("threshold: %i", threshold)); - - let* url = - try( - test - |> Yojson.Basic.Util.member("url") - |> Yojson.Basic.Util.to_string - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - debug(Printf.sprintf("url: %s", url)); - - let* sizes = - test - |> Yojson.Basic.Util.member("sizes") - |> ( - fun - | `Null => { - debug("no sizes present. using default sizes"); - Result.ok(global_config.default_sizes); - } - | `List(list) => { - debug("parsing sizes"); - list - |> OSnap_Utils.List.map_until_exception( - OSnap_Config_Utils.JSON.parse_size, - ); - } - | _ => { - Result.error( - OSnap_Response.Config_Invalid( - "sizes has an invalid format.", - None, - ), - ); - } - ); - - let* actions = - test - |> Yojson.Basic.Util.member("actions") - |> ( - fun - | `List(list) => { - debug("parsing actions"); - OSnap_Utils.List.map_until_exception( - OSnap_Config_Utils.JSON.parse_action, - list, - ); - } - | _ => Result.ok([]) - ); - - let* ignore = - test - |> Yojson.Basic.Util.member("ignore") - |> ( - fun - | `List(list) => { - debug("parsing ignore regions"); - OSnap_Utils.List.map_until_exception(parse_ignore, list); - } - | _ => Result.ok([]) - ); - - let* () = Common.collect_duplicates(~debug, sizes); - - Result.ok({only, skip, threshold, name, url, sizes, actions, ignore}); - }; - - let parse = (global_config, path) => { - let debug = OSnap_Logger.debug(~header="Config.Test.parse"); - let config = OSnap_Utils.get_file_contents(path); - debug(Printf.sprintf("parsing test file %S", path)); - - let json = config |> Yojson.Basic.from_string(~fname=path); - - try( - json - |> Yojson.Basic.Util.to_list - |> OSnap_Utils.List.map_until_exception( - parse_single_test(global_config), - ) - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - }; -}; - -module YAML = { - let parse_ignore = r => { - let debug = OSnap_Logger.debug(~header="Config.Test.YAML.parse_ignore"); - - let* size_restriction = - r |> OSnap_Config_Utils.YAML.get_string_list_option("@"); - - let* x1 = r |> OSnap_Config_Utils.YAML.get_int_option("x1"); - let* y1 = r |> OSnap_Config_Utils.YAML.get_int_option("y1"); - let* x2 = r |> OSnap_Config_Utils.YAML.get_int_option("x2"); - let* y2 = r |> OSnap_Config_Utils.YAML.get_int_option("y2"); - - let* selector = - r |> OSnap_Config_Utils.YAML.get_string_option("selector"); - - let* selector_all = - r |> OSnap_Config_Utils.YAML.get_string_option("selectorAll"); - - Common.collect_ignore( - ~debug, - ~size_restriction, - ~selector, - ~selector_all, - ~x1, - ~y1, - ~x2, - ~y2, - ); - }; - - let parse_single_test = (global_config: OSnap_Config_Types.global, test) => { - let debug = OSnap_Logger.debug(~header="Config.Test.YAML.parse"); - - let* name = test |> OSnap_Config_Utils.YAML.get_string("name"); - debug(Printf.sprintf("name: %S", name)); - - let* url = test |> OSnap_Config_Utils.YAML.get_string("url"); - debug(Printf.sprintf("url: %s", url)); - - let* only = - test - |> OSnap_Config_Utils.YAML.get_bool_option("only") - |> Result.map(Option.value(~default=false)); - debug(Printf.sprintf("only: %b", only)); - - let* skip = - test - |> OSnap_Config_Utils.YAML.get_bool_option("skip") - |> Result.map(Option.value(~default=false)); - debug(Printf.sprintf("skip: %b", only)); - - let* threshold = - test - |> OSnap_Config_Utils.YAML.get_int_option("threshold") - |> Result.map(Option.value(~default=global_config.threshold)); - debug(Printf.sprintf("threshold: %i", threshold)); - - let* sizes = - test - |> OSnap_Config_Utils.YAML.get_list_option( - "sizes", - ~parser=OSnap_Config_Utils.YAML.parse_size, - ) - |> Result.map(Option.value(~default=global_config.default_sizes)); - - let* actions = - test - |> OSnap_Config_Utils.YAML.get_list_option( - "actions", - ~parser=OSnap_Config_Utils.YAML.parse_action, - ) - |> Result.map(Option.value(~default=[])); - - let* ignore = - test - |> OSnap_Config_Utils.YAML.get_list_option( - "ignore", - ~parser=parse_ignore, - ) - |> Result.map(Option.value(~default=[])); - - let* () = Common.collect_duplicates(~debug, sizes); - - Result.ok({only, skip, threshold, name, url, sizes, actions, ignore}); - }; - - let parse = (global_config, path) => { - let debug = OSnap_Logger.debug(~header="Config.Test.YAML.parse"); - let config = OSnap_Utils.get_file_contents(path); - debug(Printf.sprintf("parsing test file %S", path)); - - let* yaml = - config - |> Yaml.of_string - |> Result.map_error(_ => - OSnap_Response.Config_Parse_Error( - Printf.sprintf("YAML could not be parsed"), - Some(path), - ) - ); - - yaml - |> ( - fun - | `A(lst) => Result.ok(lst) - | _ => - Result.error( - OSnap_Response.Config_Parse_Error( - "A test file has to be an array of tests.", - Some(path), - ), - ) - ) - |> Result.map( - OSnap_Utils.List.map_until_exception( - parse_single_test(global_config), - ), - ) - |> Result.join - |> Result.map_error(err => { - switch (err) { - | OSnap_Response.Config_Parse_Error(err, None) => - OSnap_Response.Config_Parse_Error(err, Some(path)) - | OSnap_Response.Config_Parse_Error(err, Some(path)) => - OSnap_Response.Config_Parse_Error(err, Some(path)) - | OSnap_Response.Config_Global_Not_Found => OSnap_Response.Config_Global_Not_Found - | OSnap_Response.Config_Unsupported_Format(f) => - OSnap_Response.Config_Unsupported_Format(f) - | OSnap_Response.Config_Invalid(msg, None) => - OSnap_Response.Config_Invalid(msg, Some(path)) - | OSnap_Response.Config_Invalid(msg, Some(path)) => - OSnap_Response.Config_Invalid(msg, Some(path)) - | OSnap_Response.Config_Duplicate_Tests(t) => - OSnap_Response.Config_Duplicate_Tests(t) - | OSnap_Response.Config_Duplicate_Size_Names(n) => - OSnap_Response.Config_Duplicate_Size_Names(n) - | OSnap_Response.CDP_Protocol_Error(e) => - OSnap_Response.CDP_Protocol_Error(e) - | OSnap_Response.CDP_Connection_Failed => OSnap_Response.CDP_Connection_Failed - | OSnap_Response.Invalid_Run(s) => OSnap_Response.Invalid_Run(s) - | OSnap_Response.FS_Error(e) => OSnap_Response.FS_Error(e) - | OSnap_Response.Test_Failure => OSnap_Response.Test_Failure - | OSnap_Response.Unknown_Error(e) => OSnap_Response.Unknown_Error(e) - } - }); - }; -}; - -let find = - (~root_path="/", ~pattern="**/*.osnap.json", ~ignore_patterns=[], ()) => { - let debug = OSnap_Logger.debug(~header="Config.Test.find"); - - debug(Printf.sprintf("looking for test files matching %S", pattern)); - - let pattern = pattern |> Re.Glob.glob |> Re.compile; - let ignore_patterns = - ignore_patterns - |> List.map(pattern => { - debug(Printf.sprintf("adding %S to ignore patterns", pattern)); - pattern |> Re.Glob.glob |> Re.compile; - }); - - let is_ignored = path => { - let ignored = ignore_patterns |> List.exists(Re.execp(_, path)); - if (ignored) { - debug(Printf.sprintf("ignoring %S", path)); - }; - ignored; - }; - - FileUtil.find( - Custom( - path => - if (!is_ignored(path)) { - let matches = Re.execp(pattern, path); - debug(Printf.sprintf("checking: %S", path)); - if (matches) { - debug(Printf.sprintf("matched: %S", path)); - }; - matches; - } else { - false; - }, - ), - root_path, - (acc, curr) => [curr, ...acc], - [], - ) - |> OSnap_Utils.List.map_until_exception(path => { - let* format = OSnap_Config_Utils.get_format(path); - Result.ok((path, format)); - }); -}; - -let init = config => { - let debug = OSnap_Logger.debug(~header="Config.Test.init"); - - debug("looking for test files"); - let* tests = - find( - ~root_path=config.root_path, - ~pattern=config.test_pattern, - ~ignore_patterns=config.ignore_patterns, - (), - ) - |> Result.bind( - _, - OSnap_Utils.List.map_until_exception(((path, test_format)) => - switch (test_format) { - | OSnap_Config_Types.JSON => JSON.parse(config, path) - | OSnap_Config_Types.YAML => YAML.parse(config, path) - } - ), - ) - |> Result.map(List.flatten); - - debug("looking for duplicate names in test files"); - let duplicates = - tests - |> OSnap_Utils.find_duplicates((t: OSnap_Config_Types.test) => t.name) - |> List.map((t: OSnap_Config_Types.test) => { - debug(Printf.sprintf("found test with duplicate name %S", t.name)); - t.name; - }); - - if (List.length(duplicates) != 0) { - Result.error(OSnap_Response.Config_Duplicate_Tests(duplicates)); - } else { - debug("did not find duplicates"); - Result.ok(tests); - }; -}; diff --git a/lib/OSnap_Config/OSnap_Config_Test.rei b/lib/OSnap_Config/OSnap_Config_Test.rei deleted file mode 100644 index 8ea0a1b..0000000 --- a/lib/OSnap_Config/OSnap_Config_Test.rei +++ /dev/null @@ -1,3 +0,0 @@ -let init: - OSnap_Config_Types.global => - result(list(OSnap_Config_Types.test), OSnap_Response.t); diff --git a/lib/OSnap_Config/OSnap_Config_Types.ml b/lib/OSnap_Config/OSnap_Config_Types.ml new file mode 100644 index 0000000..5d48a58 --- /dev/null +++ b/lib/OSnap_Config/OSnap_Config_Types.ml @@ -0,0 +1,48 @@ +type format = + | JSON + | YAML + +type size = + { name : string option + ; width : int + ; height : int + } + +type size_restriction = string list option + +type action = + | Function of string * size_restriction + | Scroll of [ `Selector of string | `PxAmount of int ] * size_restriction + | Click of string * size_restriction + | Type of string * string * size_restriction + | Wait of int * size_restriction + +type ignoreType = + | Coordinates of (int * int) * (int * int) * size_restriction + | Selector of string * size_restriction + | SelectorAll of string * size_restriction + +type test = + { only : bool + ; skip : bool + ; threshold : int + ; name : string + ; url : string + ; sizes : size list + ; actions : action list + ; ignore : ignoreType list + } + +type global = + { root_path : string + ; threshold : int + ; ignore_patterns : string list + ; test_pattern : string + ; base_url : string + ; fullscreen : bool + ; default_sizes : size list + ; functions : (string * action list) list + ; snapshot_directory : string + ; diff_pixel_color : int * int * int + ; parallelism : int + } diff --git a/lib/OSnap_Config/OSnap_Config_Types.re b/lib/OSnap_Config/OSnap_Config_Types.re deleted file mode 100644 index 75521fe..0000000 --- a/lib/OSnap_Config/OSnap_Config_Types.re +++ /dev/null @@ -1,48 +0,0 @@ -type format = - | JSON - | YAML; - -type size = { - name: option(string), - width: int, - height: int, -}; - -type size_restriction = option(list(string)); - -type action = - | Function(string, size_restriction) - | Scroll([ | `Selector(string) | `PxAmount(int)], size_restriction) - | Click(string, size_restriction) - | Type(string, string, size_restriction) - | Wait(int, size_restriction); - -type ignoreType = - | Coordinates((int, int), (int, int), size_restriction) - | Selector(string, size_restriction) - | SelectorAll(string, size_restriction); - -type test = { - only: bool, - skip: bool, - threshold: int, - name: string, - url: string, - sizes: list(size), - actions: list(action), - ignore: list(ignoreType), -}; - -type global = { - root_path: string, - threshold: int, - ignore_patterns: list(string), - test_pattern: string, - base_url: string, - fullscreen: bool, - default_sizes: list(size), - functions: list((string, list(action))), - snapshot_directory: string, - diff_pixel_color: (int, int, int), - parallelism: int, -}; diff --git a/lib/OSnap_Config/OSnap_Config_Utils.ml b/lib/OSnap_Config/OSnap_Config_Utils.ml new file mode 100644 index 0000000..3f16434 --- /dev/null +++ b/lib/OSnap_Config/OSnap_Config_Utils.ml @@ -0,0 +1,385 @@ +let get_format path = + path + |> Filename.extension + |> String.lowercase_ascii + |> function + | ".json" -> Result.ok OSnap_Config_Types.JSON + | ".yaml" -> Result.ok OSnap_Config_Types.YAML + | _ -> Result.error (OSnap_Response.Config_Unsupported_Format path) +;; + +let to_result_option = function + | None -> Ok None + | Some (Ok v) -> Ok (Some v) + | Some (Error e) -> Error e +;; + +let collect_action ~debug ~selector ~size_restriction ~name ~text ~timeout ~px action = + match action with + | "scroll" -> + debug "found scroll action"; + (match selector, px with + | None, None -> + Result.error + (OSnap_Response.Config_Invalid + ( "Neither selector nor px was provided for scroll action. Please provide \ + one of them." + , None )) + | Some _, Some _ -> + Result.error + (OSnap_Response.Config_Invalid + ( "Both selector and px were provided for scroll action. Please provide only \ + one of them." + , None )) + | None, Some px -> + debug (Printf.sprintf "scroll px amount is %i" px); + OSnap_Config_Types.Scroll (`PxAmount px, size_restriction) |> Result.ok + | Some selector, None -> + debug (Printf.sprintf "scroll selector is %S" selector); + OSnap_Config_Types.Scroll (`Selector selector, size_restriction) |> Result.ok) + | "click" -> + debug "found click action"; + (match selector with + | None -> + Result.error + (OSnap_Response.Config_Invalid ("no selector for click action provided", None)) + | Some selector -> + debug (Printf.sprintf "click selector is %S" selector); + OSnap_Config_Types.Click (selector, size_restriction) |> Result.ok) + | "type" -> + debug "found type action"; + (match selector, text with + | None, _ -> + debug ""; + Result.error + (OSnap_Response.Config_Invalid ("no selector for type action provided", None)) + | _, None -> + Result.error + (OSnap_Response.Config_Invalid ("no text for type action provided", None)) + | Some selector, Some text -> + debug (Printf.sprintf "type action selector is %S with text %S" selector text); + OSnap_Config_Types.Type (selector, text, size_restriction) |> Result.ok) + | "wait" -> + debug "found wait action"; + (match timeout with + | None -> + Result.error + (OSnap_Response.Config_Invalid ("no timeout for wait action provided", None)) + | Some timeout -> + debug (Printf.sprintf "timeout for wait action is %i" timeout); + OSnap_Config_Types.Wait (timeout, size_restriction) |> Result.ok) + | "function" -> + debug "found function action"; + (match name with + | None -> + Result.error + (OSnap_Response.Config_Invalid ("no name for function action provided", None)) + | Some name -> + debug (Printf.sprintf "name for function action is %s" name); + OSnap_Config_Types.Function (name, size_restriction) |> Result.ok) + | action -> + Result.error + (OSnap_Response.Config_Invalid + (Printf.sprintf "found unknown action %S" action, None)) +;; + +module JSON = struct + let ( let* ) = Result.bind + + let parse_size size = + let debug = OSnap_Logger.debug ~header:"Config.Test.parse_size" in + let* name = + try + size + |> Yojson.Basic.Util.member "name" + |> Yojson.Basic.Util.to_string_option + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* width = + try + size + |> Yojson.Basic.Util.member "width" + |> (function + | `Null -> + Result.error + (OSnap_Response.Config_Parse_Error + ( "defaultSize has an invalid format. \"width\" is required but not \ + provided!" + , None )) + | v -> Result.ok v) + |> Result.map Yojson.Basic.Util.to_int + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* height = + try + size + |> Yojson.Basic.Util.member "height" + |> (function + | `Null -> + Result.error + (OSnap_Response.Config_Parse_Error + ( "defaultSize has an invalid format. \"height\" is required but not \ + provided!" + , None )) + | v -> Result.ok v) + |> Result.map Yojson.Basic.Util.to_int + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + debug (Printf.sprintf "size is set to %ix%i" width height); + OSnap_Config_Types.{ name; width; height } |> Result.ok + ;; + + let parse_action a = + let debug = OSnap_Logger.debug ~header:"Config.Test.parse_action" in + let* action = + try + a |> Yojson.Basic.Util.member "action" |> Yojson.Basic.Util.to_string |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* size_restriction = + try + a + |> Yojson.Basic.Util.member "@" + |> Yojson.Basic.Util.to_option Yojson.Basic.Util.to_list + |> Option.map (List.map Yojson.Basic.Util.to_string) + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* px = + try + a |> Yojson.Basic.Util.member "px" |> Yojson.Basic.Util.to_int_option |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* selector = + try + a + |> Yojson.Basic.Util.member "selector" + |> Yojson.Basic.Util.to_string_option + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* text = + try + a + |> Yojson.Basic.Util.member "text" + |> Yojson.Basic.Util.to_string_option + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* timeout = + try + a + |> Yojson.Basic.Util.member "timeout" + |> Yojson.Basic.Util.to_int_option + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + let* name = + try + a + |> Yojson.Basic.Util.member "name" + |> Yojson.Basic.Util.to_string_option + |> Result.ok + with + | Yojson.Basic.Util.Type_error (message, _) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + in + collect_action ~debug ~selector ~size_restriction ~text ~timeout ~name ~px action + ;; +end + +module YAML = struct + let ( let* ) = Result.bind + + let get_list_option ~parser key obj = + obj + |> Yaml.Util.find key + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, None)) + |> Result.map + (Option.map (fun v -> + match v with + | `A l -> Result.ok l + | `Bool _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ( Printf.sprintf + "%S is in an invalid format. Expected array, got boolean." + key + , None )) + | `Float _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ( Printf.sprintf + "%S is in an invalid format. Expected array, got number." + key + , None )) + | `Null -> + Result.error + (OSnap_Response.Config_Parse_Error + ( Printf.sprintf + "%S is in an invalid format. Expected array, got null." + key + , None )) + | `O _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ( Printf.sprintf + "%S is in an invalid format. Expected array, got object." + key + , None )) + | `String _ -> + Result.error + (OSnap_Response.Config_Parse_Error + ( Printf.sprintf + "%S is in an invalid format. Expected array, got string." + key + , None )))) + |> Result.map to_result_option + |> Result.join + |> Result.map (Option.map (OSnap_Utils.List.map_until_exception parser)) + |> Result.map to_result_option + |> Result.join + ;; + + let get_string_list_option key obj = + let parser v = + Yaml.Util.to_string v + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, None)) + in + get_list_option ~parser key obj + ;; + + let get_string_option key obj = + obj + |> Yaml.Util.find key + |> Result.map (Option.map Yaml.Util.to_string) + |> Result.map to_result_option + |> Result.join + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, None)) + ;; + + let get_string ?(additional_error_message = "") key obj = + obj + |> Yaml.Util.find key + |> Result.map (Option.map Yaml.Util.to_string) + |> Result.map to_result_option + |> Result.join + |> function + | Ok (Some string) -> Result.ok string + | Ok None -> + Result.error + (OSnap_Response.Config_Parse_Error + ( Printf.sprintf + "%S is required but not provided! %s" + key + additional_error_message + , None )) + | Error (`Msg message) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + ;; + + let get_bool_option key obj = + obj + |> Yaml.Util.find key + |> Result.map (Option.map Yaml.Util.to_bool) + |> Result.map to_result_option + |> Result.join + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, None)) + ;; + + let get_bool ?(additional_error_message = "") key obj = + obj + |> Yaml.Util.find key + |> Result.map (Option.map Yaml.Util.to_bool) + |> Result.map to_result_option + |> Result.join + |> function + | Ok (Some string) -> Result.ok string + | Ok None -> + Result.error + (OSnap_Response.Config_Parse_Error + ( Printf.sprintf + "%S is required but not provided! %s" + key + additional_error_message + , None )) + | Error (`Msg message) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + ;; + + let get_int ?(additional_error_message = "") key obj = + obj + |> Yaml.Util.find key + |> Result.map (Option.map Yaml.Util.to_float) + |> Result.map to_result_option + |> Result.join + |> Result.map (Option.map Float.to_int) + |> function + | Ok (Some number) -> Result.ok number + | Ok None -> + Result.error + (OSnap_Response.Config_Parse_Error + ( Printf.sprintf + "%S is required but not provided! %s" + key + additional_error_message + , None )) + | Error (`Msg message) -> + Result.error (OSnap_Response.Config_Parse_Error (message, None)) + ;; + + let get_int_option key obj = + obj + |> Yaml.Util.find key + |> Result.map (Option.map Yaml.Util.to_float) + |> Result.map to_result_option + |> Result.join + |> Result.map (Option.map Float.to_int) + |> Result.map_error (function `Msg message -> + OSnap_Response.Config_Parse_Error (message, None)) + ;; + + let parse_size size = + let debug = OSnap_Logger.debug ~header:"Config.Test.parse_size" in + let* name = size |> get_string_option "name" in + let* height = size |> get_int "height" in + let* width = size |> get_int "width" in + debug (Printf.sprintf "adding size %ix%i" width height); + OSnap_Config_Types.{ name; width; height } |> Result.ok + ;; + + let parse_action a = + let debug = OSnap_Logger.debug ~header:"Config.Test.YAML.parse_action" in + let* size_restriction = a |> get_string_list_option "@" in + let* action = a |> get_string "action" in + let* selector = a |> get_string_option "selector" in + let* px = a |> get_int_option "px" in + let* name = a |> get_string_option "name" in + let* text = a |> get_string_option "text" in + let* timeout = a |> get_int_option "timeout" in + collect_action ~debug ~selector ~size_restriction ~text ~name ~timeout ~px action + ;; +end diff --git a/lib/OSnap_Config/OSnap_Config_Utils.re b/lib/OSnap_Config/OSnap_Config_Utils.re deleted file mode 100644 index 6389944..0000000 --- a/lib/OSnap_Config/OSnap_Config_Utils.re +++ /dev/null @@ -1,526 +0,0 @@ -let get_format = path => - path - |> Filename.extension - |> String.lowercase_ascii - |> ( - fun - | ".json" => Result.ok(OSnap_Config_Types.JSON) - | ".yaml" => Result.ok(OSnap_Config_Types.YAML) - | _ => Result.error(OSnap_Response.Config_Unsupported_Format(path)) - ); - -let to_result_option = - fun - | None => Ok(None) - | Some(Ok(v)) => Ok(Some(v)) - | Some(Error(e)) => Error(e); - -let collect_action = - ( - ~debug, - ~selector, - ~size_restriction, - ~name, - ~text, - ~timeout, - ~px, - action, - ) => { - switch (action) { - | "scroll" => - debug("found scroll action"); - switch (selector, px) { - | (None, None) => - Result.error( - OSnap_Response.Config_Invalid( - "Neither selector nor px was provided for scroll action. Please provide one of them.", - None, - ), - ) - | (Some(_), Some(_)) => - Result.error( - OSnap_Response.Config_Invalid( - "Both selector and px were provided for scroll action. Please provide only one of them.", - None, - ), - ) - | (None, Some(px)) => - debug(Printf.sprintf("scroll px amount is %i", px)); - OSnap_Config_Types.Scroll(`PxAmount(px), size_restriction) |> Result.ok; - | (Some(selector), None) => - debug(Printf.sprintf("scroll selector is %S", selector)); - OSnap_Config_Types.Scroll(`Selector(selector), size_restriction) - |> Result.ok; - }; - | "click" => - debug("found click action"); - switch (selector) { - | None => - Result.error( - OSnap_Response.Config_Invalid( - "no selector for click action provided", - None, - ), - ) - | Some(selector) => - debug(Printf.sprintf("click selector is %S", selector)); - OSnap_Config_Types.Click(selector, size_restriction) |> Result.ok; - }; - | "type" => - debug("found type action"); - switch (selector, text) { - | (None, _) => - debug(""); - Result.error( - OSnap_Response.Config_Invalid( - "no selector for type action provided", - None, - ), - ); - | (_, None) => - Result.error( - OSnap_Response.Config_Invalid( - "no text for type action provided", - None, - ), - ) - | (Some(selector), Some(text)) => - debug( - Printf.sprintf( - "type action selector is %S with text %S", - selector, - text, - ), - ); - OSnap_Config_Types.Type(selector, text, size_restriction) |> Result.ok; - }; - | "wait" => - debug("found wait action"); - switch (timeout) { - | None => - Result.error( - OSnap_Response.Config_Invalid( - "no timeout for wait action provided", - None, - ), - ) - | Some(timeout) => - debug(Printf.sprintf("timeout for wait action is %i", timeout)); - OSnap_Config_Types.Wait(timeout, size_restriction) |> Result.ok; - }; - | "function" => - debug("found function action"); - switch (name) { - | None => - Result.error( - OSnap_Response.Config_Invalid( - "no name for function action provided", - None, - ), - ) - | Some(name) => - debug(Printf.sprintf("name for function action is %s", name)); - OSnap_Config_Types.Function(name, size_restriction) |> Result.ok; - }; - | action => - Result.error( - OSnap_Response.Config_Invalid( - Printf.sprintf("found unknown action %S", action), - None, - ), - ) - }; -}; - -module JSON = { - let ( let* ) = Result.bind; - let parse_size = size => { - let debug = OSnap_Logger.debug(~header="Config.Test.parse_size"); - - let* name = - try( - size - |> Yojson.Basic.Util.member("name") - |> Yojson.Basic.Util.to_string_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* width = - try( - size - |> Yojson.Basic.Util.member("width") - |> ( - fun - | `Null => - Result.error( - OSnap_Response.Config_Parse_Error( - "defaultSize has an invalid format. \"width\" is required but not provided!", - None, - ), - ) - | v => Result.ok(v) - ) - |> Result.map(Yojson.Basic.Util.to_int) - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* height = - try( - size - |> Yojson.Basic.Util.member("height") - |> ( - fun - | `Null => - Result.error( - OSnap_Response.Config_Parse_Error( - "defaultSize has an invalid format. \"height\" is required but not provided!", - None, - ), - ) - | v => Result.ok(v) - ) - |> Result.map(Yojson.Basic.Util.to_int) - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - debug(Printf.sprintf("size is set to %ix%i", width, height)); - - OSnap_Config_Types.{name, width, height} |> Result.ok; - }; - - let parse_action = a => { - let debug = OSnap_Logger.debug(~header="Config.Test.parse_action"); - - let* action = - try( - a - |> Yojson.Basic.Util.member("action") - |> Yojson.Basic.Util.to_string - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* size_restriction = - try( - a - |> Yojson.Basic.Util.member("@") - |> Yojson.Basic.Util.to_option(Yojson.Basic.Util.to_list) - |> Option.map(List.map(Yojson.Basic.Util.to_string)) - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* px = - try( - a - |> Yojson.Basic.Util.member("px") - |> Yojson.Basic.Util.to_int_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* selector = - try( - a - |> Yojson.Basic.Util.member("selector") - |> Yojson.Basic.Util.to_string_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* text = - try( - a - |> Yojson.Basic.Util.member("text") - |> Yojson.Basic.Util.to_string_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* timeout = - try( - a - |> Yojson.Basic.Util.member("timeout") - |> Yojson.Basic.Util.to_int_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - let* name = - try( - a - |> Yojson.Basic.Util.member("name") - |> Yojson.Basic.Util.to_string_option - |> Result.ok - ) { - | Yojson.Basic.Util.Type_error(message, _) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - }; - - collect_action( - ~debug, - ~selector, - ~size_restriction, - ~text, - ~timeout, - ~name, - ~px, - action, - ); - }; -}; - -module YAML = { - let ( let* ) = Result.bind; - - let get_list_option = (~parser, key, obj) => { - obj - |> Yaml.Util.find(key) - |> Result.map_error( - fun - | `Msg(message) => OSnap_Response.Config_Parse_Error(message, None), - ) - |> Result.map( - Option.map(v => { - switch (v) { - | `A(l) => Result.ok(l) - | `Bool(_) => - Result.error( - OSnap_Response.Config_Parse_Error( - Printf.sprintf( - "%S is in an invalid format. Expected array, got boolean.", - key, - ), - None, - ), - ) - | `Float(_) => - Result.error( - OSnap_Response.Config_Parse_Error( - Printf.sprintf( - "%S is in an invalid format. Expected array, got number.", - key, - ), - None, - ), - ) - | `Null => - Result.error( - OSnap_Response.Config_Parse_Error( - Printf.sprintf( - "%S is in an invalid format. Expected array, got null.", - key, - ), - None, - ), - ) - | `O(_) => - Result.error( - OSnap_Response.Config_Parse_Error( - Printf.sprintf( - "%S is in an invalid format. Expected array, got object.", - key, - ), - None, - ), - ) - | `String(_) => - Result.error( - OSnap_Response.Config_Parse_Error( - Printf.sprintf( - "%S is in an invalid format. Expected array, got string.", - key, - ), - None, - ), - ) - } - }), - ) - |> Result.map(to_result_option) - |> Result.join - |> Result.map(Option.map(OSnap_Utils.List.map_until_exception(parser))) - |> Result.map(to_result_option) - |> Result.join; - }; - - let get_string_list_option = (key, obj) => { - let parser = v => - Yaml.Util.to_string(v) - |> Result.map_error( - fun - | `Msg(message) => - OSnap_Response.Config_Parse_Error(message, None), - ); - - get_list_option(~parser, key, obj); - }; - - let get_string_option = (key, obj) => { - obj - |> Yaml.Util.find(key) - |> Result.map(Option.map(Yaml.Util.to_string)) - |> Result.map(to_result_option) - |> Result.join - |> Result.map_error( - fun - | `Msg(message) => OSnap_Response.Config_Parse_Error(message, None), - ); - }; - - let get_string = (~additional_error_message="", key, obj) => { - obj - |> Yaml.Util.find(key) - |> Result.map(Option.map(Yaml.Util.to_string)) - |> Result.map(to_result_option) - |> Result.join - |> ( - fun - | Ok(Some(string)) => Result.ok(string) - | Ok(None) => - Result.error( - OSnap_Response.Config_Parse_Error( - Printf.sprintf( - "%S is required but not provided! %s", - key, - additional_error_message, - ), - None, - ), - ) - | Error(`Msg(message)) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - ); - }; - - let get_bool_option = (key, obj) => { - obj - |> Yaml.Util.find(key) - |> Result.map(Option.map(Yaml.Util.to_bool)) - |> Result.map(to_result_option) - |> Result.join - |> Result.map_error( - fun - | `Msg(message) => OSnap_Response.Config_Parse_Error(message, None), - ); - }; - - let get_bool = (~additional_error_message="", key, obj) => { - obj - |> Yaml.Util.find(key) - |> Result.map(Option.map(Yaml.Util.to_bool)) - |> Result.map(to_result_option) - |> Result.join - |> ( - fun - | Ok(Some(string)) => Result.ok(string) - | Ok(None) => - Result.error( - OSnap_Response.Config_Parse_Error( - Printf.sprintf( - "%S is required but not provided! %s", - key, - additional_error_message, - ), - None, - ), - ) - | Error(`Msg(message)) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - ); - }; - - let get_int = (~additional_error_message="", key, obj) => { - obj - |> Yaml.Util.find(key) - |> Result.map(Option.map(Yaml.Util.to_float)) - |> Result.map(to_result_option) - |> Result.join - |> Result.map(Option.map(Float.to_int)) - |> ( - fun - | Ok(Some(number)) => Result.ok(number) - | Ok(None) => - Result.error( - OSnap_Response.Config_Parse_Error( - Printf.sprintf( - "%S is required but not provided! %s", - key, - additional_error_message, - ), - None, - ), - ) - | Error(`Msg(message)) => - Result.error(OSnap_Response.Config_Parse_Error(message, None)) - ); - }; - - let get_int_option = (key, obj) => { - obj - |> Yaml.Util.find(key) - |> Result.map(Option.map(Yaml.Util.to_float)) - |> Result.map(to_result_option) - |> Result.join - |> Result.map(Option.map(Float.to_int)) - |> Result.map_error( - fun - | `Msg(message) => OSnap_Response.Config_Parse_Error(message, None), - ); - }; - - let parse_size = size => { - let debug = OSnap_Logger.debug(~header="Config.Test.parse_size"); - - let* name = size |> get_string_option("name"); - let* height = size |> get_int("height"); - let* width = size |> get_int("width"); - - debug(Printf.sprintf("adding size %ix%i", width, height)); - - OSnap_Config_Types.{name, width, height} |> Result.ok; - }; - - let parse_action = a => { - let debug = OSnap_Logger.debug(~header="Config.Test.YAML.parse_action"); - - let* size_restriction = a |> get_string_list_option("@"); - - let* action = a |> get_string("action"); - let* selector = a |> get_string_option("selector"); - let* px = a |> get_int_option("px"); - let* name = a |> get_string_option("name"); - let* text = a |> get_string_option("text"); - let* timeout = a |> get_int_option("timeout"); - - collect_action( - ~debug, - ~selector, - ~size_restriction, - ~text, - ~name, - ~timeout, - ~px, - action, - ); - }; -}; diff --git a/lib/OSnap_Diff/OSnap_Diff.ml b/lib/OSnap_Diff/OSnap_Diff.ml new file mode 100644 index 0000000..6adcdc1 --- /dev/null +++ b/lib/OSnap_Diff/OSnap_Diff.ml @@ -0,0 +1,112 @@ +module Io = OSnap_Diff_Io +module Diff = Odiff.Diff.MakeDiff (Io.PNG) (Io.PNG) +open Bigarray + +let ( let* ) = Result.bind + +type failState = + | Io + | Pixel of int * float + | Layout + +let diff + ~output + ?(diffPixel = 255, 0, 0) + ?(ignoreRegions = []) + ?(threshold = 0) + ~original_image_data + ~new_image_data + () + = + let* original_image = + try Io.PNG.loadImage original_image_data |> Result.ok with + | _ -> Result.error Io + in + let new_image = Io.PNG.loadImage new_image_data in + Diff.diff + original_image + new_image + ~outputDiffMask:true + ~threshold:0.1 + ~failOnLayoutChange:true + ~antialiasing:true + ~ignoreRegions + ~diffPixel + () + |> function + | Pixel (_, diffCount, _) when diffCount <= threshold -> Result.ok () + | Layout -> Result.error Layout + | Pixel (diff_mask, diffCount, diffPercentage) -> + let original_image = original_image in + let diff_mask = diff_mask in + let new_image = new_image in + let border_width = 5 in + let complete_width = + original_image.width + + border_width + + diff_mask.width + + border_width + + new_image.width + in + let complete_height = + original_image.height |> max diff_mask.height |> max new_image.height + in + let original_image_start = 0 in + let original_image_end = original_image.width in + let diff_mask_start = original_image_end + border_width in + let diff_mask_end = diff_mask_start + diff_mask.width in + let new_image_start = diff_mask_end + border_width in + let new_image_end = new_image_start + new_image.width in + let complete_image = + Array1.create int32 c_layout (complete_width * complete_height * 4) + in + let size = Array1.dim complete_image / 4 in + let row = ref (-1) in + for offset = 0 to size do + let col = offset mod complete_width in + if col = 0 then incr row; + let fill_with = + if col >= original_image_start && col < original_image_end + then Array1.unsafe_get original_image.image ((!row * original_image.width) + col) + else if col > diff_mask_start && col < diff_mask_end + then ( + let ( >> ) = Int32.shift_right in + let ( & ) = Int32.logand in + let pixel = + Array1.unsafe_get + diff_mask.image + ((!row * diff_mask.width) + (col - diff_mask_start)) + in + let alpha = pixel >> 24 & 0xFFl in + if alpha = 0xFFl + then pixel + else ( + let pixel = + Array1.unsafe_get + original_image.image + ((!row * original_image.width) + (col - diff_mask_start)) + |> Int32.to_int + in + let a = (pixel lsr 24) land 0xFF in + let b = (pixel lsr 16) land 0xFF in + let g = (pixel lsr 8) land 0xFF in + let r = (pixel lsr 0) land 0xFF in + let brightness = ((r * 54) + (g * 182) + (b * 19)) / 255 in + let mono = min 255 (brightness + 80) in + let a = (a land 0xFF) lsl 24 in + let b = (mono land 0xFF) lsl 16 in + let g = (mono land 0xFF) lsl 8 in + let r = (mono land 0xFF) lsl 0 in + Int32.of_int (a lor b lor g lor r))) + else if col > new_image_start && col <= new_image_end + then + Array1.unsafe_get + new_image.image + ((!row * new_image.width) + (col - new_image_start)) + else 0xFFFFFFFFl + in + Array1.unsafe_set complete_image offset fill_with + done; + WritePng.write_png_bigarray output complete_image complete_width complete_height; + Result.error (Pixel (diffCount, diffPercentage)) +;; diff --git a/lib/OSnap_Diff/OSnap_Diff.mli b/lib/OSnap_Diff/OSnap_Diff.mli new file mode 100644 index 0000000..23cb859 --- /dev/null +++ b/lib/OSnap_Diff/OSnap_Diff.mli @@ -0,0 +1,14 @@ +type failState = + | Io + | Pixel of int * float + | Layout + +val diff + : output:string + -> ?diffPixel:int * int * int + -> ?ignoreRegions:((int * int) * (int * int)) list + -> ?threshold:int + -> original_image_data:string + -> new_image_data:string + -> unit + -> (unit, failState) result diff --git a/lib/OSnap_Diff/OSnap_Diff.re b/lib/OSnap_Diff/OSnap_Diff.re deleted file mode 100644 index dbbcd4a..0000000 --- a/lib/OSnap_Diff/OSnap_Diff.re +++ /dev/null @@ -1,148 +0,0 @@ -module Io = OSnap_Diff_Io; -module Diff = Odiff.Diff.MakeDiff(Io.PNG, Io.PNG); -open Bigarray; -let ( let* ) = Result.bind; - -type failState = - | Io - | Pixel(int, float) - | Layout; - -let diff = - ( - ~output, - ~diffPixel=(255, 0, 0), - ~ignoreRegions=[], - ~threshold=0, - ~original_image_data, - ~new_image_data, - (), - ) => { - let* original_image = - try(Io.PNG.loadImage(original_image_data) |> Result.ok) { - | _ => Result.error(Io) - }; - let new_image = Io.PNG.loadImage(new_image_data); - - Diff.diff( - original_image, - new_image, - ~outputDiffMask=true, - ~threshold=0.1, - ~failOnLayoutChange=true, - ~antialiasing=true, - ~ignoreRegions, - ~diffPixel, - (), - ) - |> ( - fun - | Pixel((_, diffCount, _)) when diffCount <= threshold => Result.ok() - | Layout => Result.error(Layout) - | Pixel((diff_mask, diffCount, diffPercentage)) => { - let original_image = original_image; - let diff_mask = diff_mask; - let new_image = new_image; - let border_width = 5; - - let complete_width = - original_image.width - + border_width - + diff_mask.width - + border_width - + new_image.width; - - let complete_height = - original_image.height - |> max(diff_mask.height) - |> max(new_image.height); - - let original_image_start = 0; - let original_image_end = original_image.width; - - let diff_mask_start = original_image_end + border_width; - let diff_mask_end = diff_mask_start + diff_mask.width; - - let new_image_start = diff_mask_end + border_width; - let new_image_end = new_image_start + new_image.width; - - let complete_image = - Array1.create( - int32, - c_layout, - complete_width * complete_height * 4, - ); - - let size = Array1.dim(complete_image) / 4; - let row = ref(-1); - for (offset in 0 to size) { - let col = offset mod complete_width; - if (col == 0) { - incr(row); - }; - - let fill_with = - if (col >= original_image_start && col < original_image_end) { - Array1.unsafe_get( - original_image.image, - row^ * original_image.width + col, - ); - } else if (col > diff_mask_start && col < diff_mask_end) { - let (>>) = Int32.shift_right; - let (&) = Int32.logand; - - let pixel = - Array1.unsafe_get( - diff_mask.image, - row^ * diff_mask.width + (col - diff_mask_start), - ); - - let alpha = pixel >> 24 & 0xFFl; - - if (alpha == 0xFFl) { - pixel; - } else { - let pixel = - Array1.unsafe_get( - original_image.image, - row^ * original_image.width + (col - diff_mask_start), - ) - |> Int32.to_int; - - let a = pixel lsr 24 land 0xFF; - let b = pixel lsr 16 land 0xFF; - let g = pixel lsr 8 land 0xFF; - let r = pixel lsr 0 land 0xFF; - - let brightness = (r * 54 + g * 182 + b * 19) / 255; - let mono = min(255, brightness + 80); - let a = (a land 0xFF) lsl 24; - let b = (mono land 0xFF) lsl 16; - let g = (mono land 0xFF) lsl 8; - let r = (mono land 0xFF) lsl 0; - Int32.of_int(a lor b lor g lor r); - }; - } else if (col > new_image_start && col <= new_image_end) { - Array1.unsafe_get( - new_image.image, - row^ * new_image.width + (col - new_image_start), - ); - } else { - 0xFFFFFFFFl; - // AA BB GG RR - }; - - Array1.unsafe_set(complete_image, offset, fill_with); - }; - - WritePng.write_png_bigarray( - output, - complete_image, - complete_width, - complete_height, - ); - - Result.error(Pixel(diffCount, diffPercentage)); - } - ); -}; diff --git a/lib/OSnap_Diff/OSnap_Diff.rei b/lib/OSnap_Diff/OSnap_Diff.rei deleted file mode 100644 index 12ee0ca..0000000 --- a/lib/OSnap_Diff/OSnap_Diff.rei +++ /dev/null @@ -1,16 +0,0 @@ -type failState = - | Io - | Pixel(int, float) - | Layout; - -let diff: - ( - ~output: string, - ~diffPixel: (int, int, int)=?, - ~ignoreRegions: list(((int, int), (int, int)))=?, - ~threshold: int=?, - ~original_image_data: string, - ~new_image_data: string, - unit - ) => - result(unit, failState); diff --git a/lib/OSnap_Diff/OSnap_Diff_Io.ml b/lib/OSnap_Diff/OSnap_Diff_Io.ml new file mode 100644 index 0000000..7b77389 --- /dev/null +++ b/lib/OSnap_Diff/OSnap_Diff_Io.ml @@ -0,0 +1,34 @@ +open Bigarray +open Odiff + +type data = (int32, int32_elt, c_layout) Array1.t + +module PNG = struct + type t = data + + let readDirectPixel ~(x : int) ~(y : int) (img : t ImageIO.img) = + let image = (img.image : data) in + Array1.unsafe_get image ((y * img.width) + x) + ;; + + let setImgColor ~x ~y color (img : t ImageIO.img) = + let image = (img.image : data) in + Array1.unsafe_set image ((y * img.width) + x) color + ;; + + let loadImage buffer : t ImageIO.img = + let width, height, data = ReadPng.read_png_buffer buffer (String.length buffer) in + { width; height; image = data } + ;; + + let saveImage (img : t ImageIO.img) filename = + WritePng.write_png_bigarray filename img.image img.width img.height + ;; + + let freeImage (_img : t ImageIO.img) = () + + let makeSameAsLayout (img : t ImageIO.img) = + let image = Array1.create int32 c_layout (Array1.dim img.image) in + { img with image } + ;; +end diff --git a/lib/OSnap_Diff/OSnap_Diff_Io.mli b/lib/OSnap_Diff/OSnap_Diff_Io.mli new file mode 100644 index 0000000..d75afa3 --- /dev/null +++ b/lib/OSnap_Diff/OSnap_Diff_Io.mli @@ -0,0 +1,6 @@ +open Bigarray +open Odiff + +type data = (int32, int32_elt, c_layout) Array1.t + +module PNG : ImageIO.ImageIO with type t = data diff --git a/lib/OSnap_Diff/OSnap_Diff_Io.re b/lib/OSnap_Diff/OSnap_Diff_Io.re deleted file mode 100644 index 07b530e..0000000 --- a/lib/OSnap_Diff/OSnap_Diff_Io.re +++ /dev/null @@ -1,38 +0,0 @@ -open Bigarray; -open Odiff; - -type data = Array1.t(int32, int32_elt, c_layout); - -module PNG = { - type t = data; - - let readDirectPixel = (~x: int, ~y: int, img: ImageIO.img(t)) => { - let image: data = img.image; - Array1.unsafe_get(image, y * img.width + x); - }; - - let setImgColor = (~x, ~y, color, img: ImageIO.img(t)) => { - let image: data = img.image; - Array1.unsafe_set(image, y * img.width + x, color); - }; - - let loadImage = (buffer): ImageIO.img(t) => { - let (width, height, data) = - ReadPng.read_png_buffer(buffer, String.length(buffer)); - - {width, height, image: data}; - }; - - let saveImage = (img: ImageIO.img(t), filename) => { - WritePng.write_png_bigarray(filename, img.image, img.width, img.height); - }; - - let freeImage = (_img: ImageIO.img(t)) => { - (); - }; - - let makeSameAsLayout = (img: ImageIO.img(t)) => { - let image = Array1.create(int32, c_layout, Array1.dim(img.image)); - {...img, image}; - }; -}; diff --git a/lib/OSnap_Diff/OSnap_Diff_Io.rei b/lib/OSnap_Diff/OSnap_Diff_Io.rei deleted file mode 100644 index c18c8fd..0000000 --- a/lib/OSnap_Diff/OSnap_Diff_Io.rei +++ /dev/null @@ -1,6 +0,0 @@ -open Bigarray; -open Odiff; - -type data = Array1.t(int32, int32_elt, c_layout); - -module PNG: ImageIO.ImageIO with type t = data; diff --git a/lib/OSnap_Diff/ReadPng.ml b/lib/OSnap_Diff/ReadPng.ml index 66023e2..2a60ca7 100644 --- a/lib/OSnap_Diff/ReadPng.ml +++ b/lib/OSnap_Diff/ReadPng.ml @@ -1,4 +1,5 @@ external read_png_buffer - : string -> int + : string + -> int -> int * int * (int32, Bigarray.int32_elt, Bigarray.c_layout) Bigarray.Array1.t = "read_png_buffer" diff --git a/lib/OSnap_Diff/png_write/WritePng.ml b/lib/OSnap_Diff/png_write/WritePng.ml new file mode 100644 index 0000000..6d98d2d --- /dev/null +++ b/lib/OSnap_Diff/png_write/WritePng.ml @@ -0,0 +1,10 @@ +open Bigarray + +external write_png_bigarray + : string + -> (int32, int32_elt, c_layout) Array1.t + -> int + -> int + -> unit + = "write_png_bigarray" + [@@noalloc] diff --git a/lib/OSnap_Diff/png_write/WritePng.re b/lib/OSnap_Diff/png_write/WritePng.re deleted file mode 100644 index 277c112..0000000 --- a/lib/OSnap_Diff/png_write/WritePng.re +++ /dev/null @@ -1,6 +0,0 @@ -open Bigarray; - -[@noalloc] -external write_png_bigarray: - (string, Array1.t(int32, int32_elt, c_layout), int, int) => unit = - "write_png_bigarray"; diff --git a/lib/OSnap_Logger/OSnap_Logger.ml b/lib/OSnap_Logger/OSnap_Logger.ml new file mode 100644 index 0000000..2a4889b --- /dev/null +++ b/lib/OSnap_Logger/OSnap_Logger.ml @@ -0,0 +1,21 @@ +let src = Logs.Src.create "osnap" + +let init style_renderer level = + Fmt_tty.setup_std_outputs ?style_renderer (); + Logs.Src.set_level src level; + Logs.set_reporter (Logs_fmt.reporter ()) +;; + +let debug ~header message = + Logs.debug ~src (fun log -> log ~header "%a" (Fmt.styled `Faint Fmt.string) message) +;; + +let info ~header message = Logs.info ~src (fun log -> log ~header "%a" Fmt.string message) + +let warn ~header message = + Logs.warn ~src (fun log -> log ~header "%a" (Fmt.styled `Yellow Fmt.string) message) +;; + +let error ~header message = + Logs.err ~src (fun log -> log ~header "%a" (Fmt.styled `Red Fmt.string) message) +;; diff --git a/lib/OSnap_Logger/OSnap_Logger.re b/lib/OSnap_Logger/OSnap_Logger.re deleted file mode 100644 index 2b8846d..0000000 --- a/lib/OSnap_Logger/OSnap_Logger.re +++ /dev/null @@ -1,29 +0,0 @@ -let src = Logs.Src.create("osnap"); - -let init = (style_renderer, level) => { - Fmt_tty.setup_std_outputs(~style_renderer?, ()); - Logs.Src.set_level(src, level); - Logs.set_reporter(Logs_fmt.reporter()); -}; - -let debug = (~header, message) => { - Logs.debug(~src, log => { - log(~header, "%a", Fmt.styled(`Faint, Fmt.string), message) - }); -}; - -let info = (~header, message) => { - Logs.info(~src, log => log(~header, "%a", Fmt.string, message)); -}; - -let warn = (~header, message) => { - Logs.warn(~src, log => - log(~header, "%a", Fmt.styled(`Yellow, Fmt.string), message) - ); -}; - -let error = (~header, message) => { - Logs.err(~src, log => - log(~header, "%a", Fmt.styled(`Red, Fmt.string), message) - ); -}; diff --git a/lib/OSnap_Paths.ml b/lib/OSnap_Paths.ml new file mode 100644 index 0000000..7603164 --- /dev/null +++ b/lib/OSnap_Paths.ml @@ -0,0 +1,31 @@ +let get_snapshot_root_path (config : OSnap_Config.Types.global) = + config.root_path ^ config.snapshot_directory +;; + +let get_base_images_dir (config : OSnap_Config.Types.global) = + let base_path = get_snapshot_root_path config in + base_path ^ "/__base_images__" +;; + +let get_updated_dir (config : OSnap_Config.Types.global) = + let base_path = get_snapshot_root_path config in + base_path ^ "/__updated__" +;; + +let get_diff_dir (config : OSnap_Config.Types.global) = + let base_path = get_snapshot_root_path config in + base_path ^ "/__diff__" +;; + +type t = + { base : string + ; updated : string + ; diff : string + } + +let get config = + { base = get_base_images_dir config + ; updated = get_updated_dir config + ; diff = get_diff_dir config + } +;; diff --git a/lib/OSnap_Paths.re b/lib/OSnap_Paths.re deleted file mode 100644 index 0ef04c3..0000000 --- a/lib/OSnap_Paths.re +++ /dev/null @@ -1,30 +0,0 @@ -let get_snapshot_root_path = (config: OSnap_Config.Types.global) => { - config.root_path ++ config.snapshot_directory; -}; - -let get_base_images_dir = (config: OSnap_Config.Types.global) => { - let base_path = get_snapshot_root_path(config); - base_path ++ "/__base_images__"; -}; - -let get_updated_dir = (config: OSnap_Config.Types.global) => { - let base_path = get_snapshot_root_path(config); - base_path ++ "/__updated__"; -}; - -let get_diff_dir = (config: OSnap_Config.Types.global) => { - let base_path = get_snapshot_root_path(config); - base_path ++ "/__diff__"; -}; - -type t = { - base: string, - updated: string, - diff: string, -}; - -let get = config => { - base: get_base_images_dir(config), - updated: get_updated_dir(config), - diff: get_diff_dir(config), -}; diff --git a/lib/OSnap_Printer/OSnap_Printer.ml b/lib/OSnap_Printer/OSnap_Printer.ml new file mode 100644 index 0000000..c508331 --- /dev/null +++ b/lib/OSnap_Printer/OSnap_Printer.ml @@ -0,0 +1,181 @@ +open Fmt + +let test_name ~name ~width ~height = + Fmt.str_like + Fmt.stdout + "%s %a" + name + (styled `Faint string) + (Printf.sprintf "(%ix%i)" width height) +;; + +let created_message ~name ~width ~height = + Fmt.pr + "%a\t%s @." + (styled `Bold (styled `Blue string)) + "CREATE" + (test_name ~name ~width ~height) +;; + +let skipped_message ~name ~width ~height = + Fmt.pr + "%a\t%s @." + (styled `Bold (styled `Yellow string)) + "SKIP" + (test_name ~name ~width ~height) +;; + +let success_message ~name ~width ~height = + Fmt.pr + "%a\t%s @." + (styled `Bold (styled `Green string)) + "PASS" + (test_name ~name ~width ~height) +;; + +let layout_message ~print_head ~name ~width ~height = + if print_head + then + Fmt.pr + "%a\t%s %a @." + (styled `Bold (styled `Red string)) + "FAIL" + (test_name ~name ~width ~height) + (styled `Red string) + "Images have different layout." + else + Fmt.pr + "%s %a @." + (test_name ~name ~width ~height) + (styled `Red string) + "Images have different layout." +;; + +let diff_message ~print_head ~name ~width ~height ~diffCount ~diffPercentage = + if print_head + then + Fmt.pr + "%a\t%s %a @." + (styled `Bold (styled `Red string)) + "FAIL" + (test_name ~name ~width ~height) + (styled `Red string) + (Printf.sprintf "Different pixels: %i (%f%%)" diffCount diffPercentage) + else + Fmt.pr + "%s %a @." + (test_name ~name ~width ~height) + (styled `Red string) + (Printf.sprintf "Different pixels: %i (%f%%)" diffCount diffPercentage) +;; + +let corrupted_message ~print_head ~name ~width ~height = + if print_head + then + Fmt.pr + "%a\t%s %a @." + (styled `Bold (styled `Red string)) + "FAIL" + (test_name ~name ~width ~height) + (styled `Red string) + "The base image for this test is corrupted. Please regenerate the snapshot by \ + deleting the current base image!" + else + Fmt.pr + "%s %a @." + (test_name ~name ~width ~height) + (styled `Red string) + "The base image for this test is corrupted. Please regenerate the snapshot by \ + deleting the current base image!" +;; + +let stats ~test_count ~create_count ~passed_count ~failed_tests ~skipped_count ~seconds = + let ( % ) = mod_float in + let hours = + let t = Int.of_float (seconds /. 3600.) in + if t > 0 + then + Fmt.str_like + Fmt.stdout + (match t = 1 with + | true -> "%a hour, " + | false -> "%a hours, ") + (styled `Bold int) + t + else "" + in + let minutes = + let t = Int.of_float (seconds % 3600. /. 60.) in + if t > 0 || hours <> "" + then + Fmt.str_like + Fmt.stdout + (match t = 1 with + | true -> "%a minute and " + | false -> "%a minutes and ") + (styled `Bold int) + t + else "" + in + let seconds = + let t = seconds % 3600. % 60. in + Fmt.str_like + Fmt.stdout + "%a seconds" + (styled + `Bold + (float_dfrac + (match minutes = "" with + | true -> 3 + | false -> 0))) + t + in + Fmt.pr + "\n\nDone! 🚀\nI did run a total of %a snapshots in %s%s%s@." + (styled `Bold int) + test_count + hours + minutes + seconds; + Fmt.pr "Results:@."; + if create_count > 0 + then + Fmt.pr + "%a %a @." + (styled `Bold int) + create_count + (styled `Bold string) + "Snapshots created"; + if skipped_count > 0 + then + Fmt.pr + "%a %a @." + (styled `Bold (styled `Yellow int)) + skipped_count + (styled `Bold (styled `Yellow string)) + "Snapshots skipped"; + Fmt.pr + "%a %a @." + (styled `Bold (styled `Green int)) + passed_count + (styled `Bold (styled `Green string)) + "Snapshots passed"; + let failed_count = List.length failed_tests in + if failed_count > 0 + then ( + Fmt.pr + "%a %a @." + (styled `Bold (styled `Red int)) + failed_count + (styled `Bold (styled `Red string)) + "Snapshots failed"; + Fmt.pr "\n%a\n@." (styled `Bold string) "Summary of failed tests:"; + failed_tests + |> List.iter (function + | `Failed (`Io (name, width, height)) -> + corrupted_message ~print_head:false ~name ~width ~height + | `Failed (`Layout (name, width, height)) -> + layout_message ~print_head:false ~name ~width ~height + | `Failed (`Pixel (name, width, height, diffCount, diffPercentage)) -> + diff_message ~print_head:false ~name ~width ~height ~diffCount ~diffPercentage)) +;; diff --git a/lib/OSnap_Printer/OSnap_Printer.re b/lib/OSnap_Printer/OSnap_Printer.re deleted file mode 100644 index 9d501be..0000000 --- a/lib/OSnap_Printer/OSnap_Printer.re +++ /dev/null @@ -1,222 +0,0 @@ -open Fmt; - -let test_name = (~name, ~width, ~height) => { - Fmt.str_like( - Fmt.stdout, - "%s %a", - name, - styled(`Faint, string), - Printf.sprintf("(%ix%i)", width, height), - ); -}; - -let created_message = (~name, ~width, ~height) => { - Fmt.pr( - "%a\t%s @.", - styled(`Bold, styled(`Blue, string)), - "CREATE", - test_name(~name, ~width, ~height), - ); -}; - -let skipped_message = (~name, ~width, ~height) => { - Fmt.pr( - "%a\t%s @.", - styled(`Bold, styled(`Yellow, string)), - "SKIP", - test_name(~name, ~width, ~height), - ); -}; - -let success_message = (~name, ~width, ~height) => { - Fmt.pr( - "%a\t%s @.", - styled(`Bold, styled(`Green, string)), - "PASS", - test_name(~name, ~width, ~height), - ); -}; - -let layout_message = (~print_head, ~name, ~width, ~height) => - if (print_head) { - Fmt.pr( - "%a\t%s %a @.", - styled(`Bold, styled(`Red, string)), - "FAIL", - test_name(~name, ~width, ~height), - styled(`Red, string), - "Images have different layout.", - ); - } else { - Fmt.pr( - "%s %a @.", - test_name(~name, ~width, ~height), - styled(`Red, string), - "Images have different layout.", - ); - }; - -let diff_message = - (~print_head, ~name, ~width, ~height, ~diffCount, ~diffPercentage) => - if (print_head) { - Fmt.pr( - "%a\t%s %a @.", - styled(`Bold, styled(`Red, string)), - "FAIL", - test_name(~name, ~width, ~height), - styled(`Red, string), - Printf.sprintf( - "Different pixels: %i (%f%%)", - diffCount, - diffPercentage, - ), - ); - } else { - Fmt.pr( - "%s %a @.", - test_name(~name, ~width, ~height), - styled(`Red, string), - Printf.sprintf( - "Different pixels: %i (%f%%)", - diffCount, - diffPercentage, - ), - ); - }; - -let corrupted_message = (~print_head, ~name, ~width, ~height) => - if (print_head) { - Fmt.pr( - "%a\t%s %a @.", - styled(`Bold, styled(`Red, string)), - "FAIL", - test_name(~name, ~width, ~height), - styled(`Red, string), - "The base image for this test is corrupted. Please regenerate the snapshot by deleting the current base image!", - ); - } else { - Fmt.pr( - "%s %a @.", - test_name(~name, ~width, ~height), - styled(`Red, string), - "The base image for this test is corrupted. Please regenerate the snapshot by deleting the current base image!", - ); - }; - -let stats = - ( - ~test_count, - ~create_count, - ~passed_count, - ~failed_tests, - ~skipped_count, - ~seconds, - ) => { - let (%) = mod_float; - - let hours = { - let t = Int.of_float(seconds /. 3600.); - if (t > 0) { - Fmt.str_like( - Fmt.stdout, - t == 1 ? "%a hour, " : "%a hours, ", - styled(`Bold, int), - t, - ); - } else { - ""; - }; - }; - - let minutes = { - let t = Int.of_float(seconds % 3600. /. 60.); - if (t > 0 || hours != "") { - Fmt.str_like( - Fmt.stdout, - t == 1 ? "%a minute and " : "%a minutes and ", - styled(`Bold, int), - t, - ); - } else { - ""; - }; - }; - - let seconds = { - let t = seconds % 3600. % 60.; - Fmt.str_like( - Fmt.stdout, - "%a seconds", - styled(`Bold, float_dfrac(minutes == "" ? 3 : 0)), - t, - ); - }; - - Fmt.pr( - "\n\nDone! 🚀\nI did run a total of %a snapshots in %s%s%s \n@.", - styled(`Bold, int), - test_count, - hours, - minutes, - seconds, - ); - Fmt.pr("Results:@."); - - if (create_count > 0) { - Fmt.pr( - "%a %a @.", - styled(`Bold, int), - create_count, - styled(`Bold, string), - "Snapshots created", - ); - }; - - if (skipped_count > 0) { - Fmt.pr( - "%a %a @.", - styled(`Bold, styled(`Yellow, int)), - skipped_count, - styled(`Bold, styled(`Yellow, string)), - "Snapshots skipped", - ); - }; - - Fmt.pr( - "%a %a @.", - styled(`Bold, styled(`Green, int)), - passed_count, - styled(`Bold, styled(`Green, string)), - "Snapshots passed", - ); - - let failed_count = List.length(failed_tests); - if (failed_count > 0) { - Fmt.pr( - "%a %a @.", - styled(`Bold, styled(`Red, int)), - failed_count, - styled(`Bold, styled(`Red, string)), - "Snapshots failed", - ); - - Fmt.pr("\n%a\n@.", styled(`Bold, string), "Summary of failed tests:"); - failed_tests - |> List.iter( - fun - | `Failed(`Io(name, width, height)) => - corrupted_message(~print_head=false, ~name, ~width, ~height) - | `Failed(`Layout(name, width, height)) => - layout_message(~print_head=false, ~name, ~width, ~height) - | `Failed(`Pixel(name, width, height, diffCount, diffPercentage)) => - diff_message( - ~print_head=false, - ~name, - ~width, - ~height, - ~diffCount, - ~diffPercentage, - ), - ); - }; -}; diff --git a/lib/OSnap_Response/OSnap_Response.ml b/lib/OSnap_Response/OSnap_Response.ml new file mode 100644 index 0000000..a8f0ce9 --- /dev/null +++ b/lib/OSnap_Response/OSnap_Response.ml @@ -0,0 +1,13 @@ +type t = + | Config_Parse_Error of string * string option + | Config_Global_Not_Found + | Config_Unsupported_Format of string + | Config_Invalid of string * string option + | Config_Duplicate_Tests of string list + | Config_Duplicate_Size_Names of string list + | CDP_Protocol_Error of string + | CDP_Connection_Failed + | Invalid_Run of string + | FS_Error of string + | Test_Failure + | Unknown_Error of exn diff --git a/lib/OSnap_Response/OSnap_Response.re b/lib/OSnap_Response/OSnap_Response.re deleted file mode 100644 index ac40555..0000000 --- a/lib/OSnap_Response/OSnap_Response.re +++ /dev/null @@ -1,13 +0,0 @@ -type t = - | Config_Parse_Error(string, option(string)) - | Config_Global_Not_Found - | Config_Unsupported_Format(string) - | Config_Invalid(string, option(string)) - | Config_Duplicate_Tests(list(string)) - | Config_Duplicate_Size_Names(list(string)) - | CDP_Protocol_Error(string) - | CDP_Connection_Failed - | Invalid_Run(string) - | FS_Error(string) - | Test_Failure - | Unknown_Error(exn); diff --git a/lib/OSnap_Test.ml b/lib/OSnap_Test.ml new file mode 100644 index 0000000..2a9d6a2 --- /dev/null +++ b/lib/OSnap_Test.ml @@ -0,0 +1,258 @@ +module Config = OSnap_Config +module Browser = OSnap_Browser +module Diff = OSnap_Diff +module Printer = OSnap_Printer + +type t = + { url : string + ; name : string + ; size_name : string option + ; width : int + ; height : int + ; actions : Config.Types.action list + ; ignore_regions : Config.Types.ignoreType list + ; threshold : int + ; exists : bool + } + +let save_screenshot ~path data = + let open Lwt_result.Syntax in + let* io = + try Lwt_io.open_file ~mode:Output path |> Lwt_result.ok with + | _ -> + OSnap_Response.FS_Error (Printf.sprintf "Could not save screenshot to %s" path) + |> Lwt_result.fail + in + let* () = Lwt_io.write io data |> Lwt_result.ok in + Lwt_io.close io |> Lwt_result.ok +;; + +let read_file_contents ~path = + let open Lwt_result.Syntax in + let* io = + try Lwt_io.open_file ~mode:Input path |> Lwt_result.ok with + | _ -> + OSnap_Response.FS_Error (Printf.sprintf "Could not open file %S for reading" path) + |> Lwt_result.fail + in + let* data = Lwt_io.read io |> Lwt_result.ok in + let* () = Lwt_io.close io |> Lwt_result.ok in + Lwt_result.return data +;; + +let rec execute_action ~document ~global_config target size_name action = + let open Config.Types in + match action, size_name with + | Scroll (_, Some _), None -> Lwt_result.return () + | Scroll (`Selector selector, None), _ -> + target |> Browser.Actions.scroll ~document ~selector:(Some selector) ~px:None + | Scroll (`PxAmount px, None), _ -> + target |> Browser.Actions.scroll ~document ~selector:None ~px:(Some px) + | Scroll (`Selector selector, Some size_restr), Some size_name -> + if size_restr |> List.mem size_name + then target |> Browser.Actions.scroll ~document ~selector:(Some selector) ~px:None + else Lwt_result.return () + | Scroll (`PxAmount px, Some size_restr), Some size_name -> + if size_restr |> List.mem size_name + then target |> Browser.Actions.scroll ~document ~selector:None ~px:(Some px) + else Lwt_result.return () + | Click (_, Some _), None -> Lwt_result.return () + | Click (selector, None), _ -> target |> Browser.Actions.click ~document ~selector + | Click (selector, Some size_restr), Some size_name -> + if size_restr |> List.mem size_name + then target |> Browser.Actions.click ~document ~selector + else Lwt_result.return () + | Type (_, _, Some _), None -> Lwt_result.return () + | Type (selector, text, None), _ -> + target |> Browser.Actions.type_text ~document ~selector ~text + | Type (selector, text, Some size), Some size_name -> + if size |> List.mem size_name + then target |> Browser.Actions.type_text ~document ~selector ~text + else Lwt_result.return () + | Wait (_, Some _), None -> Lwt_result.return () + | Wait (ms, Some size), Some size_name -> + if size |> List.mem size_name + then ( + let timeout = float_of_int ms /. 1000.0 in + Lwt_unix.sleep timeout |> Lwt_result.ok) + else Lwt_result.return () + | Wait (ms, None), _ -> + let timeout = float_of_int ms /. 1000.0 in + Lwt_unix.sleep timeout |> Lwt_result.ok + | Function (_, Some _), None -> Lwt_result.return () + | Function (name, Some size), Some size_name -> + if size |> List.mem size_name + then ( + match global_config.functions |> List.assoc_opt name with + | Some actions -> + let open Lwt.Infix in + actions + |> Lwt_list.map_s + (execute_action ~document ~global_config target (Some size_name)) + >>= Lwt_list.fold_left_s + (fun (acc : (unit, OSnap_Response.t) Result.t) curr -> + if Result.is_ok acc && Result.is_ok curr + then Lwt.return acc + else Lwt.return curr) + (Result.ok ()) + | None -> + Lwt_result.fail + (OSnap_Response.Invalid_Run ("Tried to call non existant function " ^ name))) + else Lwt_result.return () + | Function (name, None), _ -> + (match global_config.functions |> List.assoc_opt name with + | Some actions -> + let open Lwt.Infix in + actions + |> Lwt_list.map_s (execute_action ~document ~global_config target size_name) + >>= Lwt_list.fold_left_s + (fun (acc : (unit, OSnap_Response.t) Result.t) curr -> + if Result.is_ok acc && Result.is_ok curr + then Lwt.return acc + else Lwt.return curr) + (Result.ok ()) + | None -> + Lwt_result.fail + (OSnap_Response.Invalid_Run ("Tried to call non existant function " ^ name))) +;; + +let get_ignore_regions ~document target size_name regions = + let open Lwt_result.Syntax in + let open Config.Types in + regions + |> List.filter (fun region -> + match region, size_name with + | Coordinates (_a, _b, None), _ -> true + | Coordinates (_, _, Some _), None -> false + | Coordinates (_a, _b, Some size_restr), Some size_name -> + List.mem size_name size_restr + | Selector (_, Some _), None -> false + | Selector (_, Some size_restr), Some size_name -> List.mem size_name size_restr + | Selector (_selector, None), _ -> true + | SelectorAll (_, Some _), None -> false + | SelectorAll (_, Some size_restr), Some size_name -> List.mem size_name size_restr + | SelectorAll (_selector, None), _ -> true) + |> Lwt_list.map_p (fun region -> + match region with + | Coordinates (a, b, _) -> Lwt_result.return [ a, b ] + | SelectorAll (selector, _) -> + let* quads = target |> Browser.Actions.get_quads_all ~document ~selector in + quads + |> List.map (fun ((x1, y1), (x2, y2)) -> + let x1 = Int.of_float x1 in + let y1 = Int.of_float y1 in + let x2 = Int.of_float x2 in + let y2 = Int.of_float y2 in + (x1, y1), (x2, y2)) + |> Lwt_result.return + | Selector (selector, _) -> + let* (x1, y1), (x2, y2) = + target |> Browser.Actions.get_quads ~document ~selector + in + let x1 = Int.of_float x1 in + let y1 = Int.of_float y1 in + let x2 = Int.of_float x2 in + let y2 = Int.of_float y2 in + Lwt_result.return [ (x1, y1), (x2, y2) ]) + |> Lwt.map (OSnap_Utils.List.map_until_exception (fun list -> list)) + |> Lwt_result.map List.flatten +;; + +let get_filename ?(diff = false) name width height = + if diff + then Printf.sprintf "/diff_%s_%ix%i.png" name width height + else Printf.sprintf "/%s_%ix%i.png" name width height +;; + +let run (global_config : Config.Types.global) target test = + let open Lwt_result.Syntax in + let open Lwt.Infix in + let dirs = OSnap_Paths.get global_config in + let filename = get_filename test.name test.width test.height in + let diff_filename = get_filename ~diff:true test.name test.width test.height in + let url = global_config.base_url ^ test.url in + let base_snapshot = dirs.base ^ filename in + let updated_snapshot = dirs.updated ^ filename in + let diff_image = dirs.diff ^ diff_filename in + let* () = target |> Browser.Actions.clear_cookies in + let* () = + target |> Browser.Actions.set_size ~width:(`Int test.width) ~height:(`Int test.height) + in + let* loaderId = target |> Browser.Actions.go_to ~url in + let* () = target |> Browser.Actions.wait_for_network_idle ~loaderId |> Lwt_result.ok in + let* document = target |> Browser.Actions.get_document in + let* () = + target + |> Browser.Actions.mousemove ~document ~to_:(`Coordinates (`Int (-100), `Int (-100))) + in + let* () = + test.actions + |> Lwt_list.map_s (execute_action ~document ~global_config target test.size_name) + >>= Lwt_list.fold_left_s + (fun (acc : (unit, OSnap_Response.t) Result.t) curr -> + if Result.is_ok acc && Result.is_ok curr + then Lwt.return acc + else Lwt.return curr) + (Result.ok ()) + in + let* screenshot = + target + |> Browser.Actions.screenshot ~full_size:global_config.fullscreen + |> Lwt_result.map Base64.decode_exn + in + if not test.exists + then ( + Printer.created_message ~name:test.name ~width:test.width ~height:test.height; + let* () = save_screenshot ~path:base_snapshot screenshot in + Lwt_result.return `Created) + else + let* original_image_data = read_file_contents ~path:base_snapshot in + if original_image_data = screenshot + then ( + Printer.success_message ~name:test.name ~width:test.width ~height:test.height; + Lwt_result.return `Passed) + else + let* ignoreRegions = + test.ignore_regions |> get_ignore_regions ~document target test.size_name + in + let diff = + Diff.diff + ~threshold:test.threshold + ~diffPixel:global_config.diff_pixel_color + ~ignoreRegions + ~output:diff_image + ~original_image_data + ~new_image_data:screenshot + in + match diff () with + | Ok () -> + Printer.success_message ~name:test.name ~width:test.width ~height:test.height; + Lwt_result.return `Passed + | Error Io -> + Printer.corrupted_message + ~print_head:true + ~name:test.name + ~width:test.width + ~height:test.height; + Lwt_result.return (`Failed (`Io (test.name, test.width, test.height))) + | Error Layout -> + Printer.layout_message + ~print_head:true + ~name:test.name + ~width:test.width + ~height:test.height; + let* () = save_screenshot screenshot ~path:updated_snapshot in + Lwt_result.return (`Failed (`Layout (test.name, test.width, test.height))) + | Error (Pixel (diffCount, diffPercentage)) -> + Printer.diff_message + ~print_head:true + ~name:test.name + ~width:test.width + ~height:test.height + ~diffCount + ~diffPercentage; + let* () = save_screenshot screenshot ~path:updated_snapshot in + Lwt_result.return + (`Failed + (`Pixel (test.name, test.width, test.height, diffCount, diffPercentage))) +;; diff --git a/lib/OSnap_Test.re b/lib/OSnap_Test.re deleted file mode 100644 index 9a233ce..0000000 --- a/lib/OSnap_Test.re +++ /dev/null @@ -1,386 +0,0 @@ -module Config = OSnap_Config; -module Browser = OSnap_Browser; - -module Diff = OSnap_Diff; -module Printer = OSnap_Printer; - -type t = { - url: string, - name: string, - size_name: option(string), - width: int, - height: int, - actions: list(Config.Types.action), - ignore_regions: list(Config.Types.ignoreType), - threshold: int, - exists: bool, -}; - -let save_screenshot = (~path, data) => { - open Lwt_result.Syntax; - - let* io = - try(Lwt_io.open_file(~mode=Output, path) |> Lwt_result.ok) { - | _ => - OSnap_Response.FS_Error( - Printf.sprintf("Could not save screenshot to %s", path), - ) - |> Lwt_result.fail - }; - - let* () = Lwt_io.write(io, data) |> Lwt_result.ok; - Lwt_io.close(io) |> Lwt_result.ok; -}; - -let read_file_contents = (~path) => { - open Lwt_result.Syntax; - - let* io = - try(Lwt_io.open_file(~mode=Input, path) |> Lwt_result.ok) { - | _ => - OSnap_Response.FS_Error( - Printf.sprintf("Could not open file %S for reading", path), - ) - |> Lwt_result.fail - }; - - let* data = Lwt_io.read(io) |> Lwt_result.ok; - let* () = Lwt_io.close(io) |> Lwt_result.ok; - - Lwt_result.return(data); -}; - -let rec execute_action = - (~document, ~global_config, target, size_name, action) => { - Config.Types.( - switch (action, size_name) { - | (Scroll(_, Some(_)), None) => Lwt_result.return() - | (Scroll(`Selector(selector), None), _) => - target - |> Browser.Actions.scroll( - ~document, - ~selector=Some(selector), - ~px=None, - ) - | (Scroll(`PxAmount(px), None), _) => - target - |> Browser.Actions.scroll(~document, ~selector=None, ~px=Some(px)) - | (Scroll(`Selector(selector), Some(size_restr)), Some(size_name)) => - if (size_restr |> List.mem(size_name)) { - target - |> Browser.Actions.scroll( - ~document, - ~selector=Some(selector), - ~px=None, - ); - } else { - Lwt_result.return(); - } - | (Scroll(`PxAmount(px), Some(size_restr)), Some(size_name)) => - if (size_restr |> List.mem(size_name)) { - target - |> Browser.Actions.scroll(~document, ~selector=None, ~px=Some(px)); - } else { - Lwt_result.return(); - } - - | (Click(_, Some(_)), None) => Lwt_result.return() - | (Click(selector, None), _) => - target |> Browser.Actions.click(~document, ~selector) - | (Click(selector, Some(size_restr)), Some(size_name)) => - if (size_restr |> List.mem(size_name)) { - target |> Browser.Actions.click(~document, ~selector); - } else { - Lwt_result.return(); - } - - | (Type(_, _, Some(_)), None) => Lwt_result.return() - | (Type(selector, text, None), _) => - target |> Browser.Actions.type_text(~document, ~selector, ~text) - | (Type(selector, text, Some(size)), Some(size_name)) => - if (size |> List.mem(size_name)) { - target |> Browser.Actions.type_text(~document, ~selector, ~text); - } else { - Lwt_result.return(); - } - - | (Wait(_, Some(_)), None) => Lwt_result.return() - | (Wait(ms, Some(size)), Some(size_name)) => - if (size |> List.mem(size_name)) { - let timeout = float_of_int(ms) /. 1000.0; - Lwt_unix.sleep(timeout) |> Lwt_result.ok; - } else { - Lwt_result.return(); - } - | (Wait(ms, None), _) => - let timeout = float_of_int(ms) /. 1000.0; - Lwt_unix.sleep(timeout) |> Lwt_result.ok; - - | (Function(_, Some(_)), None) => Lwt_result.return() - | (Function(name, Some(size)), Some(size_name)) => - if (size |> List.mem(size_name)) { - switch (global_config.functions |> List.assoc_opt(name)) { - | Some(actions) => - Lwt.Infix.( - actions - |> Lwt_list.map_s( - execute_action( - ~document, - ~global_config, - target, - Some(size_name), - ), - ) - >>= Lwt_list.fold_left_s( - (acc: Result.t(unit, OSnap_Response.t), curr) => - if (Result.is_ok(acc) && Result.is_ok(curr)) { - Lwt.return(acc); - } else { - Lwt.return(curr); - }, - Result.ok(), - ) - ) - | None => - Lwt_result.fail( - OSnap_Response.Invalid_Run( - "Tried to call non existant function " ++ name, - ), - ) - }; - } else { - Lwt_result.return(); - } - | (Function(name, None), _) => - switch (global_config.functions |> List.assoc_opt(name)) { - | Some(actions) => - Lwt.Infix.( - actions - |> Lwt_list.map_s( - execute_action(~document, ~global_config, target, size_name), - ) - >>= Lwt_list.fold_left_s( - (acc: Result.t(unit, OSnap_Response.t), curr) => - if (Result.is_ok(acc) && Result.is_ok(curr)) { - Lwt.return(acc); - } else { - Lwt.return(curr); - }, - Result.ok(), - ) - ) - | None => - Lwt_result.fail( - OSnap_Response.Invalid_Run( - "Tried to call non existant function " ++ name, - ), - ) - } - } - ); -}; - -let get_ignore_regions = (~document, target, size_name, regions) => { - Lwt_result.Syntax.( - Config.Types.( - regions - |> List.filter(region => { - switch (region, size_name) { - | (Coordinates(_a, _b, None), _) => true - | (Coordinates(_, _, Some(_)), None) => false - | (Coordinates(_a, _b, Some(size_restr)), Some(size_name)) => - List.mem(size_name, size_restr) - | (Selector(_, Some(_)), None) => false - | (Selector(_, Some(size_restr)), Some(size_name)) => - List.mem(size_name, size_restr) - | (Selector(_selector, None), _) => true - | (SelectorAll(_, Some(_)), None) => false - | (SelectorAll(_, Some(size_restr)), Some(size_name)) => - List.mem(size_name, size_restr) - | (SelectorAll(_selector, None), _) => true - } - }) - |> Lwt_list.map_p(region => { - switch (region) { - | Coordinates(a, b, _) => Lwt_result.return([(a, b)]) - | SelectorAll(selector, _) => - let* quads = - target |> Browser.Actions.get_quads_all(~document, ~selector); - quads - |> List.map((((x1, y1), (x2, y2))) => { - let x1 = Int.of_float(x1); - let y1 = Int.of_float(y1); - let x2 = Int.of_float(x2); - let y2 = Int.of_float(y2); - ((x1, y1), (x2, y2)); - }) - |> Lwt_result.return; - | Selector(selector, _) => - let* ((x1, y1), (x2, y2)) = - target |> Browser.Actions.get_quads(~document, ~selector); - let x1 = Int.of_float(x1); - let y1 = Int.of_float(y1); - let x2 = Int.of_float(x2); - let y2 = Int.of_float(y2); - Lwt_result.return([((x1, y1), (x2, y2))]); - } - }) - |> Lwt.map(OSnap_Utils.List.map_until_exception(list => list)) - |> Lwt_result.map(List.flatten) - ) - ); -}; - -let get_filename = (~diff=false, name, width, height) => - if (diff) { - Printf.sprintf("/diff_%s_%ix%i.png", name, width, height); - } else { - Printf.sprintf("/%s_%ix%i.png", name, width, height); - }; - -let run = (global_config: Config.Types.global, target, test) => { - open Lwt_result.Syntax; - open Lwt.Infix; - - let dirs = OSnap_Paths.get(global_config); - - let filename = get_filename(test.name, test.width, test.height); - let diff_filename = - get_filename(~diff=true, test.name, test.width, test.height); - let url = global_config.base_url ++ test.url; - let base_snapshot = dirs.base ++ filename; - let updated_snapshot = dirs.updated ++ filename; - let diff_image = dirs.diff ++ diff_filename; - - let* () = target |> Browser.Actions.clear_cookies; - - let* () = - target - |> Browser.Actions.set_size( - ~width=`Int(test.width), - ~height=`Int(test.height), - ); - - let* loaderId = target |> Browser.Actions.go_to(~url); - - let* () = - target - |> Browser.Actions.wait_for_network_idle(~loaderId) - |> Lwt_result.ok; - - let* document = target |> Browser.Actions.get_document; - - let* () = - target - |> Browser.Actions.mousemove( - ~document, - ~to_=`Coordinates((`Int(-100), `Int(-100))), - ); - - let* () = - test.actions - |> Lwt_list.map_s( - execute_action(~document, ~global_config, target, test.size_name), - ) - >>= Lwt_list.fold_left_s( - (acc: Result.t(unit, OSnap_Response.t), curr) => - if (Result.is_ok(acc) && Result.is_ok(curr)) { - Lwt.return(acc); - } else { - Lwt.return(curr); - }, - Result.ok(), - ); - - let* screenshot = - target - |> Browser.Actions.screenshot(~full_size=global_config.fullscreen) - |> Lwt_result.map(Base64.decode_exn); - - if (!test.exists) { - Printer.created_message( - ~name=test.name, - ~width=test.width, - ~height=test.height, - ); - let* () = save_screenshot(~path=base_snapshot, screenshot); - Lwt_result.return(`Created); - } else { - let* original_image_data = read_file_contents(~path=base_snapshot); - - if (original_image_data == screenshot) { - Printer.success_message( - ~name=test.name, - ~width=test.width, - ~height=test.height, - ); - Lwt_result.return(`Passed); - } else { - let* ignoreRegions = - test.ignore_regions - |> get_ignore_regions(~document, target, test.size_name); - - let diff = - Diff.diff( - ~threshold=test.threshold, - ~diffPixel=global_config.diff_pixel_color, - ~ignoreRegions, - ~output=diff_image, - ~original_image_data, - ~new_image_data=screenshot, - ); - - switch (diff()) { - | Ok () => - Printer.success_message( - ~name=test.name, - ~width=test.width, - ~height=test.height, - ); - Lwt_result.return(`Passed); - | Error(Io) => - Printer.corrupted_message( - ~print_head=true, - ~name=test.name, - ~width=test.width, - ~height=test.height, - ); - Lwt_result.return( - `Failed(`Io((test.name, test.width, test.height))), - ); - | Error(Layout) => - Printer.layout_message( - ~print_head=true, - ~name=test.name, - ~width=test.width, - ~height=test.height, - ); - let* () = save_screenshot(screenshot, ~path=updated_snapshot); - Lwt_result.return( - `Failed(`Layout((test.name, test.width, test.height))), - ); - | Error(Pixel(diffCount, diffPercentage)) => - Printer.diff_message( - ~print_head=true, - ~name=test.name, - ~width=test.width, - ~height=test.height, - ~diffCount, - ~diffPercentage, - ); - let* () = save_screenshot(screenshot, ~path=updated_snapshot); - Lwt_result.return( - `Failed( - `Pixel(( - test.name, - test.width, - test.height, - diffCount, - diffPercentage, - )), - ), - ); - }; - }; - }; -}; diff --git a/lib/OSnap_Utils/OSnap_Utils.ml b/lib/OSnap_Utils/OSnap_Utils.ml new file mode 100644 index 0000000..8bf2ee2 --- /dev/null +++ b/lib/OSnap_Utils/OSnap_Utils.ml @@ -0,0 +1,87 @@ +type platform = + | Win32 + | Win64 + | MacOS + | MacOS_ARM + | Linux + +let detect_platform () = + let win = Sys.win32 || Sys.cygwin in + match win, Sys.word_size with + | true, 64 -> Win64 + | true, _ -> Win32 + | false, _ -> + let ic = Unix.open_process_in "uname" in + let uname = input_line ic in + let () = close_in ic in + let ic = Unix.open_process_in "uname -m" in + let arch = input_line ic in + let () = close_in ic in + if uname = "Darwin" + then ( + match arch with + | "arm64" -> MacOS_ARM + | _ -> MacOS) + else Linux +;; + +let get_file_contents filename = + let ic = open_in_bin filename in + let file_length = in_channel_length ic in + let data = really_input_string ic file_length in + close_in ic; + data +;; + +let contains_substring ~search str = + let search_length = String.length search in + let len = String.length str in + try + for i = 0 to len - search_length do + let j = ref 0 in + while str.[i + !j] = search.[!j] do + incr j; + if !j = search_length then raise_notrace Exit + done + done; + false + with + | Exit -> true +;; + +let find_duplicates get_key list = + let hash = Hashtbl.create (List.length list) in + list + |> List.filter (fun item -> + if Hashtbl.mem hash (get_key item) + then true + else ( + Hashtbl.add hash (get_key item) true; + false)) +;; + +let path_of_segments paths = + paths + |> List.rev + |> List.fold_left + (fun acc curr -> + match acc with + | "" -> curr + | path -> path ^ "/" ^ curr) + "" +;; + +module List = struct + let map_until_exception fn list = + let rec loop acc list = + match list with + | [] -> Result.ok (List.rev acc) + | hd :: tl -> + let result = fn hd in + (match result with + | Ok v -> loop (v :: acc) tl + | Error e -> Result.error e) + in + loop [] list + ;; +end diff --git a/lib/OSnap_Utils/OSnap_Utils.mli b/lib/OSnap_Utils/OSnap_Utils.mli new file mode 100644 index 0000000..1b80f82 --- /dev/null +++ b/lib/OSnap_Utils/OSnap_Utils.mli @@ -0,0 +1,16 @@ +type platform = + | Win32 + | Win64 + | MacOS + | MacOS_ARM + | Linux + +val detect_platform : unit -> platform +val get_file_contents : string -> string +val contains_substring : search:string -> string -> bool +val find_duplicates : ('a -> 'b) -> 'a list -> 'a list +val path_of_segments : string list -> string + +module List : sig + val map_until_exception : ('a -> ('b, 'c) result) -> 'a list -> ('b list, 'c) result +end \ No newline at end of file diff --git a/lib/OSnap_Utils/OSnap_Utils.re b/lib/OSnap_Utils/OSnap_Utils.re deleted file mode 100644 index 6b84901..0000000 --- a/lib/OSnap_Utils/OSnap_Utils.re +++ /dev/null @@ -1,107 +0,0 @@ -type platform = - | Win32 - | Win64 - | MacOS - | MacOS_ARM - | Linux; - -let detect_platform = () => { - let win = Sys.win32 || Sys.cygwin; - - switch (win, Sys.word_size) { - | (true, 64) => Win64 - | (true, _) => Win32 - | (false, _) => - let ic = Unix.open_process_in("uname"); - let uname = input_line(ic); - let () = close_in(ic); - let ic = Unix.open_process_in("uname -m"); - let arch = input_line(ic); - let () = close_in(ic); - - if (uname == "Darwin") { - switch (arch) { - | "arm64" => MacOS_ARM - | _ => MacOS - }; - } else { - Linux; - }; - }; -}; - -let get_file_contents = filename => { - let ic = open_in_bin(filename); - let file_length = in_channel_length(ic); - let data = really_input_string(ic, file_length); - close_in(ic); - data; -}; - -let contains_substring = (~search, str) => { - let search_length = String.length(search); - let len = String.length(str); - try( - { - for (i in 0 to len - search_length) { - let j = ref(0); - while (str.[i + j^] == search.[j^]) { - incr(j); - if (j^ == search_length) { - raise_notrace(Exit); - }; - }; - }; - false; - } - ) { - | Exit => true - }; -}; - -let find_duplicates = (get_key, list) => { - let hash = Hashtbl.create(List.length(list)); - list - |> List.filter(item => - if (Hashtbl.mem(hash, get_key(item))) { - true; - } else { - Hashtbl.add(hash, get_key(item), true); - false; - } - ); -}; - -let path_of_segments = paths => { - paths - |> List.rev - |> List.fold_left( - (acc, curr) => { - switch (acc) { - | "" => curr - | path => path ++ "/" ++ curr - } - }, - "", - ); -}; - -module List = { - let map_until_exception = (fn, list) => { - [@ocaml.tailcall] - let rec loop = (acc, list) => { - switch (list) { - | [] => Result.ok(List.rev(acc)) - | [hd, ...tl] => - let result = fn(hd); - - switch (result) { - | Ok(v) => loop([v, ...acc], tl) - | Error(e) => Result.error(e) - }; - }; - }; - - loop([], list); - }; -}; diff --git a/lib/OSnap_Utils/OSnap_Utils.rei b/lib/OSnap_Utils/OSnap_Utils.rei deleted file mode 100644 index d2af289..0000000 --- a/lib/OSnap_Utils/OSnap_Utils.rei +++ /dev/null @@ -1,21 +0,0 @@ -type platform = - | Win32 - | Win64 - | MacOS - | MacOS_ARM - | Linux; - -let detect_platform: unit => platform; - -let get_file_contents: string => string; - -let contains_substring: (~search: string, string) => bool; - -let find_duplicates: ('a => 'b, list('a)) => list('a); - -let path_of_segments: list(string) => string; - -module List: { - let map_until_exception: - ('a => result('b, 'c), list('a)) => result(list('b), 'c); -}; diff --git a/lib/OSnap_Websocket/OSnap_Websocket.ml b/lib/OSnap_Websocket/OSnap_Websocket.ml new file mode 100644 index 0000000..6809276 --- /dev/null +++ b/lib/OSnap_Websocket/OSnap_Websocket.ml @@ -0,0 +1,139 @@ +open Lwt.Syntax + +let id = ref 0 + +let id () = + incr id; + !id +;; + +let close_requests = Queue.create () +let pending_requests = Queue.create () +let sent_requests = Hashtbl.create 10 +let listeners = Hashtbl.create 10 +let events = Hashtbl.create 1000 + +let call_event_handlers key message = + List.iter (fun handler -> + handler message (fun () -> + Hashtbl.remove listeners key; + Hashtbl.remove events key)) +;; + +let debug_send = OSnap_Logger.debug ~header:"Websocket >>>" +let debug_recieve = OSnap_Logger.debug ~header:"Websocket <<<" + +let websocket_handler recv send = + let close () = Websocket.Frame.close 1002 |> send in + let send_payload payload = + debug_send payload; + Websocket.Frame.create ~content:payload () |> send + in + let rec input_loop () = + let* () = Lwt.pause () in + if not (Queue.is_empty close_requests) + then ( + close_requests |> Queue.iter (fun resolver -> Lwt.wakeup_later resolver ()); + close_requests |> Queue.clear; + close ()) + else if not (Queue.is_empty pending_requests) + then ( + let key, message, resolver = Queue.take pending_requests in + let* () = send_payload message in + Hashtbl.add sent_requests key resolver; + input_loop ()) + else input_loop () + in + let react (frame : Websocket.Frame.t) = + match frame.opcode with + | Close | Continuation | Ctrl _ | Nonctrl _ -> close () + | Ping -> Websocket.Frame.create ~opcode:Pong () |> send + | Pong -> Lwt.return () + | Text | Binary -> + let response = frame.Websocket.Frame.content in + debug_recieve (String.sub response 0 (min (String.length response) 800)); + let id = + response + |> Yojson.Safe.from_string + |> Yojson.Safe.Util.member "id" + |> Yojson.Safe.Util.to_int_option + in + let method_ = + response + |> Yojson.Safe.from_string + |> Yojson.Safe.Util.member "method" + |> Yojson.Safe.Util.to_string_option + in + let sessionId = + response + |> Yojson.Safe.from_string + |> Yojson.Safe.Util.member "sessionId" + |> Yojson.Safe.Util.to_string_option + in + (match method_, sessionId with + | None, None -> () + | None, _ -> () + | Some method_, None -> + let key = method_ in + Hashtbl.add events key response; + Hashtbl.find_opt listeners key |> Option.iter (call_event_handlers key response) + | Some method_, Some sessionId -> + let key = method_ ^ sessionId in + Hashtbl.add events key response; + Hashtbl.find_opt listeners key |> Option.iter (call_event_handlers key response)); + (match id with + | None -> Lwt.return () + | Some key -> + Hashtbl.find_opt sent_requests key + |> Option.iter (fun resolver -> Lwt.wakeup_later resolver response); + Hashtbl.remove sent_requests key; + Lwt.return ()) + in + let rec react_forever () = + let* frame = recv () in + let* () = react frame in + react_forever () + in + Lwt.pick [ input_loop (); react_forever () ] +;; + +let connect url = + let orig_uri = Uri.of_string url in + let uri = Uri.with_scheme orig_uri (Some "http") in + let* endpoint = Resolver_lwt.resolve_uri ~uri Resolver_lwt_unix.system in + let default_context = Lazy.force Conduit_lwt_unix.default_ctx in + let* client = endpoint |> Conduit_lwt_unix.endp_to_client ~ctx:default_context in + let* conn = Websocket_lwt_unix.connect ~ctx:default_context client uri in + let recv () = Websocket_lwt_unix.read conn in + let send = Websocket_lwt_unix.write conn in + websocket_handler recv send +;; + +let send message = + let key = id () in + let message = message key in + let p, resolver = Lwt.wait () in + pending_requests |> Queue.add (key, message, resolver); + p +;; + +let listen ?(look_behind = true) ~event ~sessionId handler = + let key = event ^ sessionId in + let stored_listeners = Hashtbl.find_opt listeners key in + (match stored_listeners with + | None -> Hashtbl.add listeners key [ handler ] + | Some stored -> Hashtbl.replace listeners key (handler :: stored)); + if look_behind + then + Hashtbl.find_all events key + |> List.iter (fun event -> + handler event (fun () -> + Hashtbl.remove listeners key; + Hashtbl.remove events key)) +;; + +let close () = + let p, resolver = Lwt.wait () in + close_requests |> Queue.add resolver; + p +;; diff --git a/lib/OSnap_Websocket/OSnap_Websocket.mli b/lib/OSnap_Websocket/OSnap_Websocket.mli new file mode 100644 index 0000000..b148710 --- /dev/null +++ b/lib/OSnap_Websocket/OSnap_Websocket.mli @@ -0,0 +1,10 @@ +val listen + : ?look_behind:bool + -> event:string + -> sessionId:string + -> (string -> (unit -> unit) -> unit) + -> unit + +val close : unit -> unit Lwt.t +val send : (int -> string) -> string Lwt.t +val connect : string -> unit Lwt.t \ No newline at end of file diff --git a/lib/OSnap_Websocket/OSnap_Websocket.re b/lib/OSnap_Websocket/OSnap_Websocket.re deleted file mode 100644 index e7386c9..0000000 --- a/lib/OSnap_Websocket/OSnap_Websocket.re +++ /dev/null @@ -1,176 +0,0 @@ -let id = ref(0); - -let id = () => { - incr(id); - id^; -}; - -let close_requests = Queue.create(); -let pending_requests = Queue.create(); -let sent_requests = Hashtbl.create(10); -let listeners = Hashtbl.create(10); - -let events = Hashtbl.create(1000); - -let call_event_handlers = (key, message) => { - List.iter(handler => - handler( - message, - () => { - Hashtbl.remove(listeners, key); - Hashtbl.remove(events, key); - }, - ) - ); -}; - -let debug_send = OSnap_Logger.debug(~header="Websocket >>>"); -let debug_recieve = OSnap_Logger.debug(~header="Websocket <<<"); - -let websocket_handler = (recv, send) => { - let close = () => { - Websocket.Frame.close(1002) |> send; - }; - - let send_payload = payload => { - debug_send(payload); - Websocket.Frame.create(~content=payload, ()) |> send; - }; - - let rec input_loop = () => { - let%lwt () = Lwt.pause(); - if (!Queue.is_empty(close_requests)) { - close_requests |> Queue.iter(resolver => Lwt.wakeup_later(resolver, ())); - close_requests |> Queue.clear; - close(); - } else if (!Queue.is_empty(pending_requests)) { - let (key, message, resolver) = Queue.take(pending_requests); - let%lwt () = send_payload(message); - Hashtbl.add(sent_requests, key, resolver); - input_loop(); - } else { - input_loop(); - }; - }; - - let react = (frame: Websocket.Frame.t) => { - switch (frame.opcode) { - | Close - | Continuation - | Ctrl(_) - | Nonctrl(_) => close() - | Ping => Websocket.Frame.create(~opcode=Pong, ()) |> send - | Pong => Lwt.return() - | Text - | Binary => - let response = frame.Websocket.Frame.content; - debug_recieve( - String.sub(response, 0, min(String.length(response), 800)), - ); - let id = - response - |> Yojson.Safe.from_string - |> Yojson.Safe.Util.member("id") - |> Yojson.Safe.Util.to_int_option; - - let method = - response - |> Yojson.Safe.from_string - |> Yojson.Safe.Util.member("method") - |> Yojson.Safe.Util.to_string_option; - - let sessionId = - response - |> Yojson.Safe.from_string - |> Yojson.Safe.Util.member("sessionId") - |> Yojson.Safe.Util.to_string_option; - - switch (method, sessionId) { - | (None, None) => () - | (None, _) => () - | (Some(method), None) => - let key = method; - Hashtbl.add(events, key, response); - Hashtbl.find_opt(listeners, key) - |> Option.iter(call_event_handlers(key, response)); - | (Some(method), Some(sessionId)) => - let key = method ++ sessionId; - Hashtbl.add(events, key, response); - Hashtbl.find_opt(listeners, key) - |> Option.iter(call_event_handlers(key, response)); - }; - - switch (id) { - | None => Lwt.return() - | Some(key) => - Hashtbl.find_opt(sent_requests, key) - |> Option.iter(resolver => {Lwt.wakeup_later(resolver, response)}); - Hashtbl.remove(sent_requests, key); - Lwt.return(); - }; - }; - }; - - let rec react_forever = () => { - let%lwt frame = recv(); - let%lwt () = react(frame); - react_forever(); - }; - - Lwt.pick([input_loop(), react_forever()]); -}; - -let connect = url => { - let orig_uri = Uri.of_string(url); - let uri = Uri.with_scheme(orig_uri, Some("http")); - - let%lwt endpoint = Resolver_lwt.resolve_uri(~uri, Resolver_lwt_unix.system); - - let default_context = Lazy.force(Conduit_lwt_unix.default_ctx); - let%lwt client = - endpoint |> Conduit_lwt_unix.endp_to_client(~ctx=default_context); - - let%lwt conn = - Websocket_lwt_unix.connect(~ctx=default_context, client, uri); - - let recv = () => Websocket_lwt_unix.read(conn); - let send = Websocket_lwt_unix.write(conn); - - websocket_handler(recv, send); -}; - -let send = message => { - let key = id(); - let message = message(key); - let (p, resolver) = Lwt.wait(); - pending_requests |> Queue.add((key, message, resolver)); - p; -}; - -let listen = (~look_behind=true, ~event, ~sessionId, handler) => { - let key = event ++ sessionId; - let stored_listeners = Hashtbl.find_opt(listeners, key); - switch (stored_listeners) { - | None => Hashtbl.add(listeners, key, [handler]) - | Some(stored) => Hashtbl.replace(listeners, key, [handler, ...stored]) - }; - - if (look_behind) { - Hashtbl.find_all(events, key) - |> List.iter(event => { - handler( - event, - () => { - Hashtbl.remove(listeners, key); - Hashtbl.remove(events, key); - }, - ) - }); - }; -}; - -let close = () => { - let (p, resolver) = Lwt.wait(); - close_requests |> Queue.add(resolver); - p; -}; diff --git a/lib/OSnap_Websocket/OSnap_Websocket.rei b/lib/OSnap_Websocket/OSnap_Websocket.rei deleted file mode 100644 index e29b774..0000000 --- a/lib/OSnap_Websocket/OSnap_Websocket.rei +++ /dev/null @@ -1,14 +0,0 @@ -let listen: - ( - ~look_behind: bool=?, - ~event: string, - ~sessionId: string, - (string, unit => unit) => unit - ) => - unit; - -let close: unit => Lwt.t(unit); - -let send: (int => string) => Lwt.t(string); - -let connect: string => Lwt.t(unit); diff --git a/lib/OSnap_Websocket/dune b/lib/OSnap_Websocket/dune index 8a46c1a..3963792 100644 --- a/lib/OSnap_Websocket/dune +++ b/lib/OSnap_Websocket/dune @@ -7,6 +7,4 @@ lwt.unix yojson websocket - websocket-lwt-unix) - (preprocess - (pps lwt_ppx))) + websocket-lwt-unix))