├── release.sh ├── .gitignore ├── Cargo.toml ├── README.md ├── LICENSE ├── src ├── utils.rs ├── tester.rs ├── statistics.rs ├── printer.rs └── main.rs └── .github └── workflows └── publish.yml /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Install cargo-bump. Run `cargo install cargo-bump`" 4 | 5 | cargo bump $1 && git add ./Cargo.toml && git commit -m "Bumping package version" && cargo publish && git push origin main -f 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "psi-test" 3 | version = "0.3.8" 4 | edition = "2021" 5 | authors = ["Igor Brasileiro "] 6 | description = "PSI Test is a tool for run multiple time Page Speed Insight test." 7 | homepage = "https://github.com/igorbrasileiro/psi-sample-test" 8 | repository = "https://github.com/igorbrasileiro/psi-sample-test" 9 | readme = "README.md" 10 | categories = ["command-line-utilities", "development-tools"] 11 | keywords = ["PageSpeedInsight", "PSI", "lighthouse", "lhci", "psi"] 12 | license = "MIT" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | reqwest = { version = "0.11.14", features = ["json"] } 18 | tokio = { version = "1.26.0", features = ["full"] } 19 | serde = { version = "1.0.155", features = ["derive"] } 20 | serde_json = "1.0.94" 21 | futures = "0.3.27" 22 | clap = "3.1.8" 23 | csv = "1.1.6" 24 | url = "2.4.0" 25 | chrono = "0.4.26" 26 | rand = "0.8.5" 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # psi-sample 2 | 3 | PSI Test tool is an open source tool to assist web developers that runs Page Speed Insight test manually! 4 | 5 | ## Installing 6 | 7 | To install the psi-test tool, run inside the terminal: 8 | 9 | ```sh 10 | cargo install psi-test 11 | ``` 12 | 13 | If you don't have Cargo package manager for Rust install it. For more information about installation https://doc.rust-lang.org/cargo/getting-started/installation.html 14 | 15 | ## Using PSI-Test Tool 16 | > :warning: get the google page speed insight API token here: https://developers.google.com/speed/docs/insights/v5/get-started#APIKey 17 | 18 | Examples of how to run psi-test tool 19 | 20 | ### Default 21 | Using the default number-of-runs that is 20. 22 | 23 | ```sh 24 | psi-test --token=<> <> 25 | ``` 26 | 27 | ### Passing number-of-runs flag 28 | 29 | ```sh 30 | psi-test --token=<> --number-of-values=10 <> 31 | ``` 32 | 33 | For more information run: 34 | 35 | ```sh 36 | psi-test --help 37 | ``` 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Igor Brasileiro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{BufRead, BufReader, Lines}; 3 | use std::path::Path; 4 | 5 | pub fn read_lines

(filename: P) -> Lines> 6 | where 7 | P: AsRef, 8 | { 9 | let file = File::open(filename).unwrap(); 10 | BufReader::new(file).lines() 11 | } 12 | 13 | pub fn check_file_availability(filename: &str) -> String { 14 | let filename_path = Path::new(filename); 15 | 16 | if !filename_path.exists() { 17 | return filename.to_string(); 18 | } 19 | 20 | let filename_without_extension = filename_path.file_stem().unwrap().to_str().unwrap(); 21 | let file_extension = filename_path.extension().unwrap().to_str().unwrap(); 22 | 23 | let mut index = 1; 24 | let mut new_filename = format!( 25 | "{filename} ({ind}).{ext}", 26 | filename = filename_without_extension, 27 | ind = index, 28 | ext = file_extension, 29 | ); 30 | 31 | while Path::new(&*new_filename).exists() { 32 | index += 1; 33 | new_filename = format!( 34 | "{filename} ({ind}).{ext}", 35 | filename = filename_without_extension, 36 | ind = index, 37 | ext = file_extension, 38 | ); 39 | } 40 | 41 | new_filename 42 | } 43 | 44 | #[cfg(test)] 45 | mod utils_tests { 46 | #[test] 47 | fn check_file_availability() { 48 | assert_eq!("test.txt", super::check_file_availability("test.txt")); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/tester.rs: -------------------------------------------------------------------------------- 1 | // use chrono::{DateTime, Utc}; 2 | use futures::StreamExt; 3 | use rand::prelude::*; 4 | use url::Url; 5 | 6 | use crate::{Audit, Audits, Categories, Category, LHResult, PSIResult, PSIResultValues, Strategy}; 7 | 8 | const BUFFER_SIZE: usize = 15; 9 | const EMPTY_AUDIT: Audit = Audit { 10 | numeric_value: 0_f64, 11 | }; 12 | const EMPTY_LH_RESULT: LHResult = LHResult { 13 | audits: Audits { 14 | cumulative_layout_shift: EMPTY_AUDIT, 15 | first_contentful_paint: EMPTY_AUDIT, 16 | js_execution_time: EMPTY_AUDIT, 17 | largest_contentful_paint: EMPTY_AUDIT, 18 | speed_index: EMPTY_AUDIT, 19 | time_to_interactive: EMPTY_AUDIT, 20 | total_blocking_time: EMPTY_AUDIT, 21 | }, 22 | categories: Categories { 23 | performance: Category { score: 0_f64 }, 24 | }, 25 | }; 26 | 27 | fn add_query_param( 28 | url_str: &str, 29 | param_name: &str, 30 | param_value: &str, 31 | ) -> Result { 32 | let mut url = Url::parse(url_str)?; 33 | url.query_pairs_mut().append_pair(param_name, param_value); 34 | Ok(url.into()) 35 | } 36 | 37 | /// This methods makes requests to google PSI API in batches with BUFFER_SIZE and add the result 38 | /// into a return list. 39 | /// This APIs has a though throttling and multiple times returns errors, so, when errors happen, 40 | /// this method doesn't add the failed result in the return list. 41 | pub async fn get_page_audits( 42 | url: &str, 43 | token: &str, 44 | number_of_runs: i8, 45 | strategy: Strategy, 46 | ) -> Result { 47 | let mut rng = rand::thread_rng(); 48 | 49 | let list_urls = (0..number_of_runs).map(|_| { 50 | format!("https://www.googleapis.com/pagespeedonline/v5/runPagespeed?key={api_key}&url={url}&strategy={strategy}&category=performance", url = add_query_param(url, "__v", &format!("{}", rng.gen::())).unwrap(), api_key = token, strategy = strategy) 51 | }).collect::>(); 52 | let client = reqwest::Client::new(); 53 | 54 | let list_responses = futures::stream::iter(list_urls.iter().map(|url| client.get(url).send())) 55 | .buffer_unordered(BUFFER_SIZE) 56 | .collect::>() 57 | .await; 58 | 59 | let mut list_audits = Vec::new(); 60 | for res in list_responses { 61 | let audit = match res { 62 | Ok(result) => match result.json::().await { 63 | Ok(json) => LHResult { 64 | audits: json.lighthouse_result.audits, 65 | categories: json.lighthouse_result.categories, 66 | }, 67 | Err(error) => { 68 | println!( 69 | "Error mounting lighthouse result {site}. \n {error}", 70 | site = url, 71 | error = error 72 | ); 73 | 74 | EMPTY_LH_RESULT 75 | } 76 | }, 77 | Err(error) => { 78 | println!( 79 | "Problem mounting audits {site}. \n {error}", 80 | site = url, 81 | error = error 82 | ); 83 | 84 | EMPTY_LH_RESULT 85 | } 86 | }; 87 | 88 | if audit.audits.speed_index.numeric_value == 0_f64 { 89 | continue; 90 | } 91 | 92 | list_audits.push(audit); 93 | } 94 | 95 | Ok(map_audits(&list_audits)) 96 | } 97 | 98 | pub fn map_audits(lh_results: &[LHResult]) -> PSIResultValues { 99 | return PSIResultValues { 100 | cumulative_layout_shift: lh_results 101 | .iter() 102 | .map(|result| result.audits.cumulative_layout_shift.numeric_value) 103 | .collect(), 104 | first_contentful_paint: lh_results 105 | .iter() 106 | .map(|result| result.audits.first_contentful_paint.numeric_value) 107 | .collect(), 108 | js_execution_time: lh_results 109 | .iter() 110 | .map(|result| result.audits.js_execution_time.numeric_value) 111 | .collect(), 112 | largest_contentful_paint: lh_results 113 | .iter() 114 | .map(|result| result.audits.largest_contentful_paint.numeric_value) 115 | .collect(), 116 | speed_index: lh_results 117 | .iter() 118 | .map(|result| result.audits.speed_index.numeric_value) 119 | .collect(), 120 | time_to_interactive: lh_results 121 | .iter() 122 | .map(|result| result.audits.time_to_interactive.numeric_value) 123 | .collect(), 124 | total_blocking_time: lh_results 125 | .iter() 126 | .map(|result| result.audits.total_blocking_time.numeric_value) 127 | .collect(), 128 | score: lh_results 129 | .iter() 130 | .map(|result| result.categories.performance.score) 131 | .collect(), 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /src/statistics.rs: -------------------------------------------------------------------------------- 1 | use crate::{PSIResultValues, PSIStatisticResult}; 2 | 3 | const Z_VALUE: f64 = 1.96_f64; // z-value for 95% confidence level. 4 | 5 | pub fn mean(results: &[f64], number_of_runs: i8) -> f64 { 6 | return results.iter().sum::() / number_of_runs as f64; 7 | } 8 | 9 | pub fn calculate_mean( 10 | page_results: &PSIResultValues, 11 | number_of_runs: i8, 12 | ) -> PSIStatisticResult { 13 | PSIStatisticResult { 14 | cumulative_layout_shift: mean(&page_results.cumulative_layout_shift, number_of_runs), 15 | first_contentful_paint: mean(&page_results.first_contentful_paint, number_of_runs), 16 | js_execution_time: mean(&page_results.js_execution_time, number_of_runs), 17 | largest_contentful_paint: mean(&page_results.largest_contentful_paint, number_of_runs), 18 | speed_index: mean(&page_results.speed_index, number_of_runs), 19 | time_to_interactive: mean(&page_results.time_to_interactive, number_of_runs), 20 | total_blocking_time: mean(&page_results.total_blocking_time, number_of_runs), 21 | score: mean(&page_results.score, number_of_runs), 22 | } 23 | } 24 | 25 | pub fn std_deviation(data: &[f64], mean: f64, number_of_runs: i8) -> f64 { 26 | return data 27 | .iter() 28 | .map(|value| { 29 | let diff = mean - value; 30 | 31 | diff * diff 32 | }) 33 | .sum::() 34 | / number_of_runs as f64; 35 | } 36 | 37 | pub fn calculate_deviation( 38 | page_results: &PSIResultValues, 39 | page_mean: &PSIStatisticResult, 40 | number_of_runs: i8, 41 | ) -> PSIStatisticResult { 42 | PSIStatisticResult { 43 | cumulative_layout_shift: std_deviation( 44 | &page_results.cumulative_layout_shift, 45 | page_mean.cumulative_layout_shift, 46 | number_of_runs, 47 | ), 48 | first_contentful_paint: std_deviation( 49 | &page_results.first_contentful_paint, 50 | page_mean.first_contentful_paint, 51 | number_of_runs, 52 | ), 53 | js_execution_time: std_deviation( 54 | &page_results.js_execution_time, 55 | page_mean.js_execution_time, 56 | number_of_runs, 57 | ), 58 | largest_contentful_paint: std_deviation( 59 | &page_results.largest_contentful_paint, 60 | page_mean.largest_contentful_paint, 61 | number_of_runs, 62 | ), 63 | speed_index: std_deviation( 64 | &page_results.speed_index, 65 | page_mean.speed_index, 66 | number_of_runs, 67 | ), 68 | time_to_interactive: std_deviation( 69 | &page_results.time_to_interactive, 70 | page_mean.time_to_interactive, 71 | number_of_runs, 72 | ), 73 | total_blocking_time: std_deviation( 74 | &page_results.total_blocking_time, 75 | page_mean.total_blocking_time, 76 | number_of_runs, 77 | ), 78 | score: std_deviation(&page_results.score, page_mean.score, number_of_runs), 79 | } 80 | } 81 | 82 | // Reference: https://www.dummies.com/article/academics-the-arts/math/statistics/how-to-calculate-a-confidence-interval-for-a-population-mean-when-you-know-its-standard-deviation-169722/ 83 | pub fn confidence_interval(mean: f64, std_deviation: f64, number_of_runs: i8) -> (f64, f64) { 84 | // margin error = z value * std_deviation / sqrt (number_of_runs) 85 | let margin_error = Z_VALUE * (std_deviation / (number_of_runs as f64).sqrt()); 86 | 87 | (mean - margin_error, mean + margin_error) 88 | } 89 | 90 | pub fn calculate_confidence_interval( 91 | mean: &PSIStatisticResult, 92 | std_deviation: &PSIStatisticResult, 93 | number_of_runs: i8, 94 | ) -> PSIStatisticResult<(f64, f64)> { 95 | PSIStatisticResult::<(f64, f64)> { 96 | cumulative_layout_shift: confidence_interval( 97 | mean.cumulative_layout_shift, 98 | std_deviation.cumulative_layout_shift, 99 | number_of_runs, 100 | ), 101 | first_contentful_paint: confidence_interval( 102 | mean.first_contentful_paint, 103 | std_deviation.first_contentful_paint, 104 | number_of_runs, 105 | ), 106 | js_execution_time: confidence_interval( 107 | mean.js_execution_time, 108 | std_deviation.js_execution_time, 109 | number_of_runs, 110 | ), 111 | largest_contentful_paint: confidence_interval( 112 | mean.largest_contentful_paint, 113 | std_deviation.largest_contentful_paint, 114 | number_of_runs, 115 | ), 116 | speed_index: confidence_interval( 117 | mean.speed_index, 118 | std_deviation.speed_index, 119 | number_of_runs, 120 | ), 121 | time_to_interactive: confidence_interval( 122 | mean.time_to_interactive, 123 | std_deviation.time_to_interactive, 124 | number_of_runs, 125 | ), 126 | total_blocking_time: confidence_interval( 127 | mean.total_blocking_time, 128 | std_deviation.total_blocking_time, 129 | number_of_runs, 130 | ), 131 | score: confidence_interval(mean.score, std_deviation.score, number_of_runs), 132 | } 133 | } 134 | 135 | pub fn median(list: &[f64]) -> f64 { 136 | let number_of_runs: usize = list.len(); 137 | let index = number_of_runs / 2; 138 | 139 | // Sort list to get the middle value 140 | let mut sorted_list = list.to_owned(); 141 | sorted_list.sort_by(|a, b| a.partial_cmp(b).unwrap()); 142 | 143 | if number_of_runs % 2 == 1 { 144 | // odd 145 | *sorted_list.get(index).unwrap() 146 | } else { 147 | // even 148 | let first_median = sorted_list.get(index).unwrap(); 149 | let second_median = sorted_list.get(index + 1).unwrap(); 150 | 151 | (first_median + second_median) / 2_f64 152 | } 153 | } 154 | 155 | // pub fn calculate_median(page_results: &PSIResultValues) -> PSIStatisticResult { 156 | // return PSIStatisticResult { 157 | // cumulative_layout_shift: median(&page_results.cumulative_layout_shift), 158 | // first_contentful_paint: median(&page_results.first_contentful_paint), 159 | // js_execution_time: median(&page_results.js_execution_time), 160 | // largest_contentful_paint: median(&page_results.largest_contentful_paint), 161 | // speed_index: median(&page_results.speed_index), 162 | // time_to_interactive: median(&page_results.time_to_interactive), 163 | // total_blocking_time: median(&page_results.total_blocking_time), 164 | // score: median(&page_results.score), 165 | // }; 166 | // } 167 | -------------------------------------------------------------------------------- /src/printer.rs: -------------------------------------------------------------------------------- 1 | use csv::Writer; 2 | use serde::Serialize; 3 | use std::error::Error; 4 | use std::fs::File; 5 | use std::io; 6 | 7 | use crate::utils::check_file_availability; 8 | use crate::PSIStatisticResult; 9 | 10 | fn print_table_result( 11 | page_mean: &PSIStatisticResult, 12 | page_std_deviation: &PSIStatisticResult, 13 | page_confidence_interval: &PSIStatisticResult<(f64, f64)>, 14 | ) { 15 | println!("| Metric | Mean | Standard deviation | Confidence Interval (95%) |"); 16 | println!("|--------|--------|--------|--------|"); 17 | 18 | println!( 19 | "| Cumulative Layout shift (CLS) | {mean:.2} | {std_deviation:.2} | [{ci_min:.2}, {ci_max:.2}] |", 20 | mean = page_mean.cumulative_layout_shift, 21 | std_deviation = page_std_deviation.cumulative_layout_shift, 22 | ci_min = page_confidence_interval.cumulative_layout_shift.0, 23 | ci_max = page_confidence_interval.cumulative_layout_shift.1, 24 | ); 25 | println!( 26 | "| First Contentful Paint (FCP) | {mean:.2} | {std_deviation:.2} | [{ci_min:.2}, {ci_max:.2}] |", 27 | mean = page_mean.first_contentful_paint, 28 | std_deviation = page_std_deviation.first_contentful_paint, 29 | ci_min = page_confidence_interval.first_contentful_paint.0, 30 | ci_max = page_confidence_interval.first_contentful_paint.1, 31 | ); 32 | println!( 33 | "| Largest Contentful Paint (LCP) | {mean:.2} | {std_deviation:.2} | [{ci_min:.2}, {ci_max:.2}] |", 34 | mean = page_mean.largest_contentful_paint, 35 | std_deviation = page_std_deviation.largest_contentful_paint, 36 | 37 | ci_min = page_confidence_interval.largest_contentful_paint.0, 38 | ci_max = page_confidence_interval.largest_contentful_paint.1, 39 | ); 40 | println!( 41 | "| Time to Interactive (TTI) | {mean:.2} | {std_deviation:.2} | [{ci_min:.2}, {ci_max:.2}] |", 42 | mean = page_mean.time_to_interactive, 43 | std_deviation = page_std_deviation.time_to_interactive, 44 | 45 | ci_min = page_confidence_interval.time_to_interactive.0, 46 | ci_max = page_confidence_interval.time_to_interactive.1, 47 | ); 48 | println!( 49 | "| Total Blocking Time (TBT) | {mean:.2} | {std_deviation:.2} | [{ci_min:.2}, {ci_max:.2}] |", 50 | mean = page_mean.total_blocking_time, 51 | std_deviation = page_std_deviation.total_blocking_time, 52 | 53 | ci_min = page_confidence_interval.total_blocking_time.0, 54 | ci_max = page_confidence_interval.total_blocking_time.1, 55 | ); 56 | println!( 57 | "| Performance score | {mean:.3} | {std_deviation:.6} | [{ci_min:.6}, {ci_max:.6}] |", 58 | mean = page_mean.score, 59 | std_deviation = page_std_deviation.score, 60 | ci_min = page_confidence_interval.score.0, 61 | ci_max = page_confidence_interval.score.1, 62 | ); 63 | println!( 64 | "| JavaScript Execution Time | {mean:.2} | {std_deviation:.2} | [{ci_min:.2}, {ci_max:.2}] |", 65 | mean = page_mean.js_execution_time, 66 | std_deviation = page_std_deviation.js_execution_time, 67 | 68 | ci_min = page_confidence_interval.js_execution_time.0, 69 | ci_max = page_confidence_interval.js_execution_time.1, 70 | ); 71 | println!( 72 | "| Speed Index | {mean:.2} | {std_deviation:.2} | [{ci_min:.2}, {ci_max:.2}] |", 73 | mean = page_mean.speed_index, 74 | std_deviation = page_std_deviation.speed_index, 75 | ci_min = page_confidence_interval.speed_index.0, 76 | ci_max = page_confidence_interval.speed_index.1, 77 | ); 78 | } 79 | 80 | pub fn print_md( 81 | page_url: &str, 82 | success_runs: i8, 83 | page_mean: &PSIStatisticResult, 84 | page_std_deviation: &PSIStatisticResult, 85 | page_confidence_interval: &PSIStatisticResult<(f64, f64)>, 86 | ) { 87 | println!( 88 | "Some tests failed, the number of success tests is: {}", 89 | success_runs 90 | ); 91 | println!("Page result - {url}", url = page_url); 92 | print_table_result(page_mean, page_std_deviation, page_confidence_interval); 93 | } 94 | 95 | pub fn print_json( 96 | page_url: &str, 97 | success_runs: i8, 98 | page_mean: &PSIStatisticResult, 99 | page_std_deviation: &PSIStatisticResult, 100 | page_confidence_interval: &PSIStatisticResult<(f64, f64)>, 101 | ) { 102 | let json = serde_json::json!({ 103 | "url": page_url, 104 | "success_runs": success_runs, 105 | "cumulative_layout_shift": { 106 | "mean": page_mean.cumulative_layout_shift, 107 | "std_dev": page_std_deviation.cumulative_layout_shift, 108 | "confidence_interval": page_confidence_interval.cumulative_layout_shift, 109 | }, 110 | "first_contentful_paint": { 111 | "mean": page_mean.first_contentful_paint, 112 | "std_dev": page_std_deviation.first_contentful_paint , 113 | "confidence_interval": page_confidence_interval.first_contentful_paint, 114 | }, 115 | "largest_contentful_paint": { 116 | "mean": page_mean.largest_contentful_paint, 117 | "std_dev": page_std_deviation.largest_contentful_paint , 118 | "confidence_interval": page_confidence_interval.largest_contentful_paint, 119 | }, 120 | "time_to_interactive": { 121 | "mean": page_mean.time_to_interactive, 122 | "std_dev": page_std_deviation.time_to_interactive, 123 | "confidence_interval": page_confidence_interval.time_to_interactive, 124 | }, 125 | "total_blocking_time": { 126 | "mean": page_mean.total_blocking_time, 127 | "std_dev": page_std_deviation.total_blocking_time, 128 | "confidence_interval": page_confidence_interval.total_blocking_time, 129 | }, 130 | "score": { 131 | "mean": page_mean.score, 132 | "std_dev": page_std_deviation.score, 133 | "confidence_interval": page_confidence_interval.score, 134 | }, 135 | "js_execution_time": { 136 | "mean": page_mean.js_execution_time, 137 | "std_dev": page_std_deviation.js_execution_time, 138 | "confidence_interval": page_confidence_interval.js_execution_time, 139 | }, 140 | "speed_index" :{ 141 | "mean": page_mean.speed_index, 142 | "std_dev": page_std_deviation.speed_index, 143 | "confidence_interval": page_confidence_interval.speed_index, 144 | } 145 | }); 146 | println!("{}", serde_json::to_string_pretty(&json).unwrap()) 147 | } 148 | 149 | #[derive(Serialize)] 150 | pub struct Row<'a> { 151 | #[serde(rename = "Store")] 152 | pub url: &'a str, 153 | 154 | #[serde(rename = "Desktop - Mean")] 155 | pub d_mean: f64, 156 | 157 | #[serde(rename = "Desktop - Median")] 158 | pub d_median: f64, 159 | 160 | #[serde(rename = "Mobile - Mean")] 161 | pub m_mean: f64, 162 | 163 | #[serde(rename = "Mobile - Median")] 164 | pub m_median: f64, 165 | } 166 | 167 | pub struct CSVPrinter { 168 | csv_writer: Writer, 169 | } 170 | 171 | impl CSVPrinter { 172 | pub fn new() -> CSVPrinter { 173 | CSVPrinter { 174 | csv_writer: Writer::from_path(check_file_availability("./output.csv")).unwrap(), 175 | } 176 | } 177 | 178 | pub fn write_line(&mut self, row: Row) -> Result<(), Box> { 179 | self.csv_writer.serialize(row)?; 180 | 181 | Ok(()) 182 | } 183 | 184 | pub fn flush(&mut self) -> io::Result<()> { 185 | self.csv_writer.flush() 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # https://bl.ocks.org/PurpleBooth/84b3d7d6669f77d5a53801a258ed269a 2 | name: lint, test and publish 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/cache@v1 15 | with: 16 | path: ~/.cargo/registry 17 | key: '${{ runner.os }}-cargo-registry-${{ hashFiles(''**/Cargo.lock'') }}' 18 | - uses: actions/cache@v1 19 | with: 20 | path: ~/.cargo/git 21 | key: '${{ runner.os }}-cargo-index-${{ hashFiles(''**/Cargo.lock'') }}' 22 | - uses: actions/cache@v1 23 | with: 24 | path: target 25 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 26 | - name: Run check 27 | run: cargo check 28 | 29 | test: 30 | runs-on: '${{ matrix.os }}' 31 | strategy: 32 | matrix: 33 | include: 34 | # TODO: fix it 35 | # - os: macos-latest 36 | - os: ubuntu-latest 37 | - os: windows-latest 38 | steps: 39 | - uses: actions/checkout@v3 40 | - uses: actions/cache@v1 41 | with: 42 | path: ~/.cargo/registry 43 | key: '${{ runner.os }}-cargo-registry-${{ hashFiles(''**/Cargo.lock'') }}' 44 | - uses: actions/cache@v1 45 | with: 46 | path: ~/.cargo/git 47 | key: '${{ runner.os }}-cargo-index-${{ hashFiles(''**/Cargo.lock'') }}' 48 | - uses: actions/cache@v1 49 | with: 50 | path: target 51 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 52 | - name: Run Test 53 | run: cargo test --verbose 54 | - name: Run Cargo Run 55 | run: cargo run -- -h 56 | 57 | lints: 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: actions/cache@v1 62 | with: 63 | path: ~/.cargo/registry 64 | key: '${{ runner.os }}-cargo-registry-${{ hashFiles(''**/Cargo.lock'') }}' 65 | - uses: actions/cache@v1 66 | with: 67 | path: ~/.cargo/git 68 | key: '${{ runner.os }}-cargo-index-${{ hashFiles(''**/Cargo.lock'') }}' 69 | - uses: actions/cache@v1 70 | with: 71 | path: target 72 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 73 | - uses: actions-rs/toolchain@v1 74 | with: 75 | profile: minimal 76 | toolchain: nightly 77 | override: true 78 | components: 'rustfmt, clippy' 79 | - name: Run cargo fmt 80 | run: cargo fmt --all -- --check 81 | - uses: actions-rs/cargo@v1 82 | with: 83 | command: clippy 84 | args: '-- -D warnings' 85 | 86 | version: 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@master 90 | with: 91 | lfs: true 92 | - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* 93 | - id: get_previous_version 94 | run: echo ::set-output name=PREVIOUS_VERSION::$(git describe --tags "$(git rev-list --tags --max-count=1)") 95 | shell: bash 96 | - id: semvers 97 | uses: WyriHaximus/github-action-next-semvers@master 98 | with: 99 | version: '${{ steps.get_previous_version.outputs.PREVIOUS_VERSION }}' 100 | - run: mkdir -p ./version 101 | - if: "!contains(github.event.head_commit.message, 'BC BREAK') && !contains(github.event.head_commit.message, 'Signed-off-by: dependabot-preview[bot] ')" 102 | run: echo "$VERSION" >./version/version 103 | env: 104 | VERSION: ${{ steps.semvers.outputs.v_minor }} 105 | - if: "contains(github.event.head_commit.message, 'Signed-off-by: dependabot-preview[bot] ')" 106 | run: echo "$VERSION" >./version/version 107 | env: 108 | VERSION: ${{ steps.semvers.outputs.v_patch }} 109 | - run: echo "$VERSION" > ./version/version 110 | env: 111 | VERSION: ${{ steps.semvers.outputs.v_major }} 112 | if: "contains(github.event.head_commit.message, 'BC BREAK')" 113 | - uses: actions/upload-artifact@master 114 | with: 115 | name: version 116 | path: ./version/version 117 | 118 | 119 | build: 120 | needs: 121 | - version 122 | - lints 123 | - test 124 | - check 125 | runs-on: '${{ matrix.os }}' 126 | strategy: 127 | matrix: 128 | include: 129 | - os: macos-latest 130 | target: x86_64-apple-darwin 131 | suffix: '' 132 | - os: ubuntu-latest 133 | target: x86_64-unknown-linux-gnu 134 | suffix: '' 135 | - os: windows-latest 136 | target: x86_64-pc-windows-msvc 137 | suffix: .exe 138 | steps: 139 | - uses: actions/checkout@master 140 | with: 141 | lfs: true 142 | - id: get_repository_name 143 | run: echo ::set-output name=REPOSITORY_NAME::$(echo "$GITHUB_REPOSITORY" | awk -F / '{print $2}' | sed -e "s/:refs//") 144 | shell: bash 145 | - uses: actions/download-artifact@master 146 | with: 147 | name: version 148 | - id: get_version 149 | run: 'echo ::set-output "name=VERSION::$(cat ./version/version)"' 150 | shell: bash 151 | - uses: actions/cache@v1 152 | with: 153 | path: ~/.cargo/registry 154 | key: '${{ runner.os }}-cargo-registry-${{ hashFiles(''**/Cargo.lock'') }}' 155 | - uses: actions/cache@v1 156 | with: 157 | path: ~/.cargo/git 158 | key: '${{ runner.os }}-cargo-index-${{ hashFiles(''**/Cargo.lock'') }}' 159 | - uses: actions/cache@v1 160 | with: 161 | path: target 162 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 163 | - name: Install cargo cargo-bump 164 | run: cargo install cargo-bump 165 | - shell: bash 166 | env: 167 | VERSION: '${{ steps.get_version.outputs.VERSION }}' 168 | run: cargo bump patch 169 | - name: Cargo Build 170 | env: 171 | VERSION: '${{ steps.get_version.outputs.VERSION }}' 172 | REPOSITORY_NAME: '${{ steps.get_repository_name.outputs.REPOSITORY_NAME }}' 173 | run: cargo build --release 174 | # - uses: actions/upload-artifact@master 175 | # with: 176 | # name: ${{ steps.get_repository_name.outputs.REPOSITORY_NAME }}-${{ matrix.target }} 177 | # path: ./target/release/${{ steps.get_repository_name.outputs.REPOSITORY_NAME }}${{ matrix.suffix }} 178 | 179 | # release: 180 | # if: github.ref == 'refs/heads/main' 181 | # needs: 182 | # - build 183 | # runs-on: ubuntu-latest 184 | # steps: 185 | # - uses: actions/checkout@master 186 | # with: 187 | # lfs: true 188 | # - id: get_repository_name 189 | # run: echo ::set-output name=REPOSITORY_NAME::$(echo "$GITHUB_REPOSITORY" | awk -F / '{print $2}' | sed -e "s/:refs//") 190 | # shell: bash 191 | # - uses: actions/download-artifact@master 192 | # with: 193 | # name: version 194 | # - id: get_version 195 | # run: 'echo ::set-output name=VERSION::$(cat ./version/version)' 196 | # shell: bash 197 | # - uses: actions/download-artifact@master 198 | # with: 199 | # name: ${{ steps.get_repository_name.outputs.REPOSITORY_NAME }}-x86_64-unknown-linux-gnu 200 | # - uses: actions/download-artifact@master 201 | # with: 202 | # name: ${{ steps.get_repository_name.outputs.REPOSITORY_NAME }}-x86_64-apple-darwin 203 | # - uses: actions/download-artifact@master 204 | # with: 205 | # name: ${{ steps.get_repository_name.outputs.REPOSITORY_NAME }}-x86_64-pc-windows-msvc 206 | # - name: Install cargo cargo-bump 207 | # run: cargo install cargo-bump 208 | # - shell: bash 209 | # env: 210 | # VERSION: '${{ steps.get_version.outputs.VERSION }}' 211 | # run: cargo bump patch 212 | # - uses: stefanzweifel/git-auto-commit-action@v4.1.3 213 | # with: 214 | # commit_message: Bump cargo version 215 | # branch: ${{ github.head_ref }} 216 | # file_pattern: Cargo.toml 217 | # push_options: '--force' 218 | # - id: create_release 219 | # uses: actions/create-release@v1.0.0 220 | # env: 221 | # GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' 222 | # with: 223 | # tag_name: '${{ steps.get_version.outputs.VERSION }}' 224 | # release_name: 'Release ${{ steps.get_version.outputs.VERSION }}' 225 | # draft: false 226 | # prerelease: false 227 | # - id: publish_cargo 228 | # uses: katyo/publish-crates@v1 229 | # with: 230 | # registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} 231 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{Arg, Command}; 2 | use reqwest::Error; 3 | use serde::Deserialize; 4 | 5 | mod printer; 6 | mod statistics; 7 | mod tester; 8 | mod utils; 9 | 10 | const SAMPLE: i8 = 20; 11 | 12 | #[derive(Debug, Deserialize)] 13 | pub enum Strategy { 14 | MOBILE, 15 | DESKTOP, 16 | } 17 | 18 | impl std::fmt::Display for Strategy { 19 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 20 | match *self { 21 | Strategy::MOBILE => write!(f, "mobile"), 22 | Strategy::DESKTOP => write!(f, "desktop"), 23 | } 24 | } 25 | } 26 | 27 | #[derive(Deserialize, Debug)] 28 | struct Audit { 29 | #[serde(rename = "numericValue")] 30 | numeric_value: f64, 31 | } 32 | 33 | #[derive(Deserialize, Debug)] 34 | struct Audits { 35 | #[serde(rename = "cumulative-layout-shift")] 36 | cumulative_layout_shift: Audit, 37 | 38 | #[serde(rename = "first-contentful-paint")] 39 | first_contentful_paint: Audit, 40 | 41 | #[serde(rename = "bootup-time")] 42 | js_execution_time: Audit, 43 | 44 | #[serde(rename = "largest-contentful-paint")] 45 | largest_contentful_paint: Audit, 46 | 47 | #[serde(rename = "speed-index")] 48 | speed_index: Audit, 49 | 50 | #[serde(rename = "interactive")] 51 | time_to_interactive: Audit, 52 | 53 | #[serde(rename = "total-blocking-time")] 54 | total_blocking_time: Audit, 55 | } 56 | 57 | #[derive(Deserialize, Debug)] 58 | struct Category { 59 | score: f64, 60 | } 61 | 62 | #[derive(Deserialize, Debug)] 63 | struct Categories { 64 | performance: Category, 65 | } 66 | 67 | #[derive(Deserialize, Debug)] 68 | pub struct LHResult { 69 | audits: Audits, 70 | categories: Categories, 71 | } 72 | 73 | #[derive(Deserialize, Debug)] 74 | struct PSIResult { 75 | #[serde(rename = "lighthouseResult")] 76 | lighthouse_result: LHResult, 77 | } 78 | 79 | pub struct PSIResultValues { 80 | cumulative_layout_shift: Vec, 81 | first_contentful_paint: Vec, 82 | js_execution_time: Vec, 83 | largest_contentful_paint: Vec, 84 | speed_index: Vec, 85 | time_to_interactive: Vec, 86 | total_blocking_time: Vec, 87 | score: Vec, 88 | } 89 | 90 | #[derive(Debug)] 91 | pub struct PSIStatisticResult { 92 | cumulative_layout_shift: T, 93 | first_contentful_paint: T, 94 | js_execution_time: T, 95 | largest_contentful_paint: T, 96 | speed_index: T, 97 | time_to_interactive: T, 98 | total_blocking_time: T, 99 | score: T, 100 | } 101 | 102 | async fn batch_tests( 103 | url: &str, 104 | token: &str, 105 | number_of_runs: i8, 106 | printer: &mut printer::CSVPrinter, 107 | ) -> bool { 108 | let mobile_page_result = tester::get_page_audits(url, token, number_of_runs, Strategy::MOBILE) 109 | .await 110 | .unwrap(); 111 | // Handle if some test failed 112 | if mobile_page_result.score.len() != number_of_runs as usize { 113 | return false; 114 | } 115 | 116 | let desktop_page_result = 117 | tester::get_page_audits(url, token, number_of_runs, Strategy::DESKTOP) 118 | .await 119 | .unwrap(); 120 | // Handle if some test failed 121 | if desktop_page_result.score.len() != number_of_runs as usize { 122 | return false; 123 | } 124 | 125 | let mobile_page_mean = statistics::calculate_mean(&mobile_page_result, number_of_runs); 126 | let mobile_page_median = statistics::median(&mobile_page_result.score); 127 | 128 | let desktop_page_mean = statistics::calculate_mean(&desktop_page_result, number_of_runs); 129 | let desktop_page_median = statistics::median(&desktop_page_result.score); 130 | 131 | let _x = printer.write_line(printer::Row { 132 | url, 133 | d_mean: desktop_page_mean.score, 134 | d_median: desktop_page_median, 135 | m_mean: mobile_page_mean.score, 136 | m_median: mobile_page_median, 137 | }); 138 | 139 | let _x = printer.flush(); 140 | 141 | true 142 | } 143 | 144 | async fn run_batch_tests(filename: &str, token: &str, number_of_runs: i8) -> bool { 145 | let urls = utils::read_lines(filename); 146 | let mut failed_urls: Vec = Vec::new(); 147 | 148 | let mut csv_printer = printer::CSVPrinter::new(); 149 | 150 | for url in urls.map_while(Result::ok) { 151 | println!("Testing {url}", url = url); 152 | 153 | let test_finished = batch_tests(&url, token, number_of_runs, &mut csv_printer).await; 154 | 155 | if !test_finished { 156 | failed_urls.push(url.clone()); 157 | } 158 | } 159 | 160 | // Handle failed urls until failed_urls list is empty 161 | for qtt in 0..2 { 162 | let urls_size = failed_urls.len(); 163 | 164 | for url_idx in 0..urls_size { 165 | // from last to first 166 | let idx = (urls_size - 1) - url_idx; 167 | let url = failed_urls[idx].clone(); 168 | 169 | println!("Retesting {url} {qtt}x", url = url, qtt = qtt); 170 | 171 | let test_finished = batch_tests(&url, token, number_of_runs, &mut csv_printer).await; 172 | 173 | if !test_finished { 174 | continue; 175 | } 176 | 177 | failed_urls.remove(idx); 178 | } 179 | } 180 | 181 | for url in failed_urls { 182 | println!("Test failed for {url} after two retries", url = url); 183 | } 184 | 185 | let _x = csv_printer.flush(); 186 | 187 | true 188 | } 189 | 190 | struct TestResult { 191 | page_mean: PSIStatisticResult, 192 | page_deviation: PSIStatisticResult, 193 | page_confidence_interval: PSIStatisticResult<(f64, f64)>, 194 | success_runs: i8, 195 | } 196 | async fn run_single_tests( 197 | page_url: &str, 198 | token: &str, 199 | number_of_runs: i8, 200 | strategy: Strategy, 201 | ) -> TestResult { 202 | let page_result = &tester::get_page_audits(page_url, token, number_of_runs, strategy) 203 | .await 204 | .unwrap(); 205 | 206 | let _nruns = page_result.score.len() as i8; 207 | 208 | let page_mean = statistics::calculate_mean(page_result, _nruns); 209 | 210 | let page_deviation = statistics::calculate_deviation(page_result, &page_mean, _nruns); 211 | 212 | let page_confidence_interval = 213 | statistics::calculate_confidence_interval(&page_mean, &page_deviation, _nruns); 214 | 215 | TestResult { 216 | page_mean, 217 | page_deviation, 218 | page_confidence_interval, 219 | success_runs: _nruns, 220 | } 221 | } 222 | 223 | async fn psi_test() -> Result<(), Error> { 224 | let matches = Command::new("psi-tests") 225 | .about("PSI Tests is a tool to run multiple page speed insight tests.") 226 | .long_about( 227 | "PSI Tests is a tool to run multiple page speed insight tests and get the mean and standard deviation from some metrics. 228 | Example: run 10 tests from a specific url 229 | psi-test --token= --number-of-runs=10 https://www.google.com 230 | 231 | Example: run 5 tests for multiples urls 232 | psi-test --token= --number_of_runs=5 -B ./input.txt", 233 | ) 234 | // Change if crate_version start work again 235 | .version(env!("CARGO_PKG_VERSION")) 236 | .arg( 237 | Arg::new("token") 238 | .value_name("TOKEN_VALUE") 239 | .required(true) 240 | .short('T') 241 | .long("token") 242 | .help("Google cloud token to access Page Speed Insights API. For more informartion: https://developers.google.com/speed/docs/insights/v5/get-started#APIKey"), 243 | ) 244 | .arg( 245 | Arg::new("number-of-runs") 246 | .value_name("NUMBER") 247 | .short('N') 248 | .long("number-of-runs") 249 | .help("Number of PSI tests for each page."), 250 | ) 251 | .arg( 252 | Arg::new("first-page") 253 | .help("Page URL.") 254 | .index(1) 255 | ) 256 | .arg( 257 | Arg::new("batch") 258 | .value_name("INPUT") 259 | .short('B') 260 | .long("batch-file") 261 | .help("Batch file allow pass a TXT input file with URLs, line by line, to be tested.") 262 | ) 263 | .arg( 264 | // https://developers.google.com/speed/docs/insights/v5/reference/pagespeedapi/runpagespeed#response 265 | Arg::new("strategy") 266 | .value_name("STRATEGY") 267 | .short('S') 268 | .long("strategy") 269 | .help("The analysis strategy (desktop or mobile) to use, and mobile is the default. 270 | 271 | Acceptable values are: 272 | \"desktop\": Fetch and analyze the URL for desktop browsers 273 | \"mobile\": Fetch and analyze the URL for mobile devices 274 | 275 | This value isn't used when batch_tests flag is present." 276 | ) 277 | ) 278 | .arg( 279 | Arg::new("output-format") 280 | .value_name("OUTPUT_FORMAT") 281 | .short('F') 282 | .long("output-format") 283 | .help("output-format can be: md for markdown, json for json. --output-format: md|json.") 284 | ) 285 | .get_matches(); 286 | 287 | // Required value 288 | let token = matches.value_of("token").expect("Token is required!"); 289 | let number_of_runs = match matches.value_of("number-of-runs") { 290 | Some(value) => value.parse::().unwrap(), 291 | None => SAMPLE, 292 | }; 293 | 294 | // Run batch tests 295 | if let Some(batch) = matches.value_of("batch") { 296 | run_batch_tests(batch, token, number_of_runs).await; 297 | 298 | return Ok(()); 299 | } 300 | 301 | // Required value 302 | let page_url = matches 303 | .value_of("first-page") 304 | .expect("Page URL is required"); 305 | 306 | let strategy = match matches.value_of("strategy") { 307 | Some(value) => { 308 | if value.parse::().unwrap().eq("desktop") { 309 | Strategy::DESKTOP 310 | } else { 311 | Strategy::MOBILE 312 | } 313 | } 314 | None => Strategy::MOBILE, 315 | }; 316 | 317 | let output_format = matches.value_of("output-format").unwrap_or("json"); 318 | 319 | let test_result = run_single_tests(page_url, token, number_of_runs, strategy).await; 320 | 321 | if output_format == "md" { 322 | printer::print_md( 323 | page_url, 324 | test_result.success_runs, 325 | &test_result.page_mean, 326 | &test_result.page_deviation, 327 | &test_result.page_confidence_interval, 328 | ); 329 | } else if output_format == "json" { 330 | printer::print_json( 331 | page_url, 332 | test_result.success_runs, 333 | &test_result.page_mean, 334 | &test_result.page_deviation, 335 | &test_result.page_confidence_interval, 336 | ) 337 | } 338 | 339 | Ok(()) 340 | } 341 | 342 | #[tokio::main] 343 | async fn main() -> Result<(), Error> { 344 | psi_test().await?; 345 | 346 | Ok(()) 347 | } 348 | --------------------------------------------------------------------------------