ghtool 0.10.4

A command-line tool for interacting with Github API with some specialized features oriented around Checks
Documentation
use crate::commands::command::CheckError;
use eyre::Result;
use lazy_static::lazy_static;
use regex::Regex;

const TIMESTAMP_PATTERN: &str = r"(?P<timestamp>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)";

lazy_static! {
    /// Regex to match a timestamp and single space after it
    static ref TIMESTAMP: Regex = Regex::new(&format!(r"{TIMESTAMP_PATTERN}\s?")).unwrap();
    static ref JEST_FAIL_LINE: Regex =
        Regex::new(r"(?P<fail>FAIL)\s+(?P<path>[a-zA-Z0-9._-]*/[a-zA-Z0-9./_-]*)").unwrap();
}

#[derive(Debug, Clone, PartialEq)]
pub struct JestPath {
    pub path: String,
    pub lines: Vec<String>,
}

#[derive(PartialEq, Debug)]
enum State {
    LookingForFail,
    ParsingFail,
}

#[derive(Debug)]
pub struct JestLogParser {
    state: State,
    current_fail: Option<CheckError>,
    all_fails: Vec<CheckError>,
    current_fail_start_col: usize,
    current_fail_lines: Vec<String>,
}

impl JestLogParser {
    pub fn new() -> Self {
        JestLogParser {
            state: State::LookingForFail,
            current_fail: None,
            all_fails: Vec::new(),
            current_fail_start_col: 0,
            current_fail_lines: Vec::new(),
        }
    }

    fn parse_line(&mut self, raw_line: &str) -> Result<(), eyre::Error> {
        let line_no_timestamp = TIMESTAMP.replace(raw_line, "");

        match self.state {
            State::LookingForFail => {
                if let Some(caps) = JEST_FAIL_LINE.captures(&line_no_timestamp) {
                    self.current_fail_start_col = caps.name("fail").unwrap().start();
                    let path = caps.name("path").unwrap().as_str().to_string();
                    // Get line discarding things before the column where FAIL starts
                    let line = line_no_timestamp
                        .chars()
                        .skip(self.current_fail_start_col)
                        .collect::<String>();
                    self.current_fail = Some(CheckError {
                        lines: vec![line.to_string()],
                        path,
                    });
                    self.state = State::ParsingFail;
                }
            }
            State::ParsingFail => {
                if line_no_timestamp.len() > self.current_fail_start_col
                    && line_no_timestamp.chars().nth(self.current_fail_start_col) != Some(' ')
                {
                    let mut current_fail = std::mem::take(&mut self.current_fail).unwrap();

                    // Remove trailing empty lines
                    if let Some(last_non_empty_line) =
                        current_fail.lines.iter().rposition(|line| !line.is_empty())
                    {
                        current_fail.lines.truncate(last_non_empty_line + 1);
                    }

                    self.all_fails.push(current_fail);
                    self.current_fail_lines = Vec::new();
                    self.state = State::LookingForFail;
                } else {
                    // Get line discarding things before the column where FAIL starts
                    let line = line_no_timestamp
                        .chars()
                        .skip(self.current_fail_start_col)
                        .collect::<String>();

                    self.current_fail.as_mut().unwrap().lines.push(line);
                }
            }
        }
        Ok(())
    }

    pub fn parse(log: &str) -> Result<Vec<CheckError>> {
        let mut parser = JestLogParser::new();

        for line in log.lines() {
            parser.parse_line(line)?;
        }

        Ok(parser.get_output())
    }

    pub fn get_output(self) -> Vec<CheckError> {
        self.all_fails
            .into_iter()
            .fold(Vec::new(), |mut acc, fail| {
                if !acc.contains(&fail) {
                    acc.push(fail);
                }
                acc
            })
    }
}

impl Default for JestLogParser {
    fn default() -> Self {
        Self::new()
    }
}

// Tests
#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn test_extract_failing_tests() {
        let logs = r#"
2021-05-04T18:24:29.000Z FAIL src/components/MyComponent/MyComponent.test.tsx
2021-05-04T18:24:29.000Z   ● Test suite failed to run
2021-05-04T18:24:29.000Z     TypeError: Cannot read property 'foo' of undefined
2021-05-04T18:24:29.000Z
2021-05-04T18:24:29.000Z       1 | import React from 'react';
2021-05-04T18:24:29.000Z PASS src/components/MyComponent/MyComponent.test.tsx
2021-05-04T18:24:29.000Z FAIL src/components/MyComponent/MyComponent2.test.tsx
2021-05-04T18:24:29.000Z   ● Test suite failed to run
2021-05-04T18:24:29.000Z     TypeError: Cannot read property 'foo' of undefined
2021-05-04T18:24:29.000Z
2021-05-04T18:24:29.000Z       1 | import React from 'react';
2021-05-04T18:24:29.000Z PASS src/components/MyComponent/MyComponent2.test.tsx"#;

        let failing_tests = JestLogParser::parse(logs).unwrap();
        assert_eq!(
            failing_tests,
            vec![
                CheckError {
                    path: "src/components/MyComponent/MyComponent.test.tsx".to_string(),
                    lines: vec![
                        "FAIL src/components/MyComponent/MyComponent.test.tsx".to_string(),
                        "  ● Test suite failed to run".to_string(),
                        "    TypeError: Cannot read property 'foo' of undefined".to_string(),
                        "".to_string(),
                        "      1 | import React from 'react';".to_string(),
                    ]
                },
                CheckError {
                    path: "src/components/MyComponent/MyComponent2.test.tsx".to_string(),
                    lines: vec![
                        "FAIL src/components/MyComponent/MyComponent2.test.tsx".to_string(),
                        "  ● Test suite failed to run".to_string(),
                        "    TypeError: Cannot read property 'foo' of undefined".to_string(),
                        "".to_string(),
                        "      1 | import React from 'react';".to_string(),
                    ]
                },
            ]
        );
    }

    #[test]
    fn test_extract_failing_tests_2() {
        let logs = r#"
2023-06-28T21:11:38.9421220Z > ghtool-test-repo@1.0.0 test
2023-06-28T21:11:38.9428514Z > jest ./src --color --ci --shard=1/2
2023-06-28T21:11:38.9429089Z 
2023-06-28T21:11:43.1619050Z  FAIL  src/test2.test.ts
2023-06-28T21:11:43.1623893Z   test2
2023-06-28T21:11:43.1629746Z     ✓ succeeds (3 ms)
2023-06-28T21:11:43.1630396Z     ✕ fails (5 ms)
2023-06-28T21:11:43.1630949Z 
2023-06-28T21:11:43.1631448Z   ● test2 › fails
2023-06-28T21:11:43.1631750Z 
2023-06-28T21:11:43.1632455Z     expect(received).toBe(expected) // Object.is equality
2023-06-28T21:11:43.1633081Z 
2023-06-28T21:11:43.1633381Z     Expected: false
2023-06-28T21:11:43.1633800Z     Received: true
2023-06-28T21:11:43.1634250Z 
2023-06-28T21:11:43.1634753Z        5 |
2023-06-28T21:11:43.1635444Z        6 |   it("fails", () => {
2023-06-28T21:11:43.1636318Z     >  7 |     expect(true).toBe(false);
2023-06-28T21:11:43.1637060Z          |                  ^
2023-06-28T21:11:43.1642719Z        8 |   });
2023-06-28T21:11:43.1647216Z        9 | });
2023-06-28T21:11:43.1648590Z       10 |
2023-06-28T21:11:43.1649650Z 
2023-06-28T21:11:43.1651496Z       at Object.<anonymous> (src/test2.test.ts:7:18)
2023-06-28T21:11:43.1652032Z 
2023-06-28T21:11:43.1664383Z Test Suites: 1 failed, 1 total
2023-06-28T21:11:43.1665139Z Tests:       1 failed, 1 passed, 2 total
2023-06-28T21:11:43.1665683Z Snapshots:   0 total
2023-06-28T21:11:43.1666152Z Time:        3.464 s
2023-06-28T21:11:43.1666769Z Ran all test suites matching /.\/src/i."#;

        let failing_tests = JestLogParser::parse(logs).unwrap();
        assert_eq!(
            failing_tests,
            vec![CheckError {
                path: "src/test2.test.ts".to_string(),
                lines: vec![
                    "FAIL  src/test2.test.ts".to_string(),
                    " test2".to_string(),
                    "   ✓ succeeds (3 ms)".to_string(),
                    "   ✕ fails (5 ms)".to_string(),
                    "".to_string(),
                    " ● test2 › fails".to_string(),
                    "".to_string(),
                    "   expect(received).toBe(expected) // Object.is equality".to_string(),
                    "".to_string(),
                    "   Expected: false".to_string(),
                    "   Received: true".to_string(),
                    "".to_string(),
                    "      5 |".to_string(),
                    "      6 |   it(\"fails\", () => {".to_string(),
                    "   >  7 |     expect(true).toBe(false);".to_string(),
                    "        |                  ^".to_string(),
                    "      8 |   });".to_string(),
                    "      9 | });".to_string(),
                    "     10 |".to_string(),
                    "".to_string(),
                    "     at Object.<anonymous> (src/test2.test.ts:7:18)".to_string(),
                ],
            },]
        );
    }

    #[test]
    fn test_extract_failing_test_files() {
        let logs = r#"
2021-05-04T18:24:29.000Z FAIL src/components/MyComponent/MyComponent.test.tsx
2021-05-04T18:24:29.000Z   ● Test suite failed to run
2021-05-04T18:24:29.000Z     TypeError: Cannot read property 'foo' of undefined
2021-05-04T18:24:29.000Z
2021-05-04T18:24:29.000Z       1 | import React from 'react';
2021-05-04T18:24:29.000Z PASS src/components/MyComponent/MyComponent2.test.tsx
2021-05-04T18:24:29.000Z FAIL src/components/MyComponent/MyComponent3.test.tsx
2021-05-04T18:24:29.000Z   ● Test suite failed to run
2021-05-04T18:24:29.000Z     TypeError: Cannot read property 'foo' of undefined
2021-05-04T18:24:29.000Z
2021-05-04T18:24:29.000Z       1 | import React from 'react';
2021-05-04T18:24:29.000Z PASS src/components/MyComponent/MyComponent4.test.tsx"#;

        let failing_tests = JestLogParser::parse(logs).unwrap();
        let failing_test_files: Vec<String> = failing_tests
            .iter()
            .map(|jest_path| jest_path.path.clone())
            .collect();

        assert_eq!(
            failing_test_files,
            vec![
                "src/components/MyComponent/MyComponent.test.tsx".to_string(),
                "src/components/MyComponent/MyComponent3.test.tsx".to_string(),
            ]
        );
    }

    #[test]
    fn test_remove_duplicate_check_errors() {
        let logs = r#"
2023-09-14T12:22:30.2648458Z
2023-09-14T12:22:30.2648458Z FAIL src/components/MyComponent/MyComponent3.test.tsx
2023-09-14T12:22:30.2648458Z   ● Test suite failed to run
2023-09-14T12:22:30.2648458Z     TypeError: Cannot read property 'foo' of undefined
2023-09-14T12:22:30.2648458Z
2023-09-14T12:22:30.2648458Z       1 | import React from 'react';
2023-09-14T12:22:30.2648458Z 
2023-09-14T12:22:30.2649146Z Summary of all failing tests
2023-09-14T12:22:30.2648458Z FAIL src/components/MyComponent/MyComponent3.test.tsx
2023-09-14T12:22:30.2648458Z   ● Test suite failed to run
2023-09-14T12:22:30.2648458Z     TypeError: Cannot read property 'foo' of undefined
2023-09-14T12:22:30.2648458Z
2023-09-14T12:22:30.2648458Z       1 | import React from 'react';
2023-09-14T12:22:30.2673693Z 
2023-09-14T12:22:30.2673711Z 
2023-09-14T12:22:30.2678119Z Test Suites: 1 failed, 67 passed, 68 total
2023-09-14T12:22:30.2679079Z Tests:       1 failed, 469 passed, 470 total
2023-09-14T12:22:30.2680281Z Snapshots:   60 passed, 60 total
2023-09-14T12:22:30.2680933Z Time:        216.339 s
        "#;

        let failing_tests = JestLogParser::parse(logs).unwrap();
        assert_eq!(failing_tests.len(), 1);
    }

    #[test]
    fn test_jest_in_docker() {
        let logs = r#"
2023-12-14T12:24:25.7014935Z test_1            | $ jest -c jest.config.test.js
2023-12-14T12:24:43.7723478Z test_1            | PASS src/a.test.ts (16.764 s)
2023-12-14T12:24:53.1189316Z test_1            | FAIL src/b.test.ts
2023-12-14T12:24:53.1486488Z test_1            |   ● test › return test things
2023-12-14T12:24:53.1488314Z test_1            |
2023-12-14T12:24:53.1489247Z test_1            |     expect(received).toMatchObject(expected)
2023-12-14T12:24:53.1490238Z test_1            |
2023-12-14T12:24:53.1490994Z test_1            |     - Expected  - 1
2023-12-14T12:24:53.1491871Z test_1            |     + Received  + 0
2023-12-14T12:24:53.1492657Z test_1            |
2023-12-14T12:24:53.1493405Z test_1            |     @@ -17,9 +17,8 @@
2023-12-14T12:24:53.1662308Z test_1            |     -       "testId": undefined,
2023-12-14T12:24:53.1684564Z test_1            |           },
2023-12-14T12:24:53.1724498Z test_1            |         },
2023-12-14T12:24:53.1764019Z test_1            |       ]
2023-12-14T12:24:53.1788159Z test_1            |
2023-12-14T12:24:53.1790147Z test_1            |     > 62 |     expect(result).toMatchObject([
2023-12-14T12:24:53.1790859Z test_1            |          |                    ^
2023-12-14T12:24:53.1794182Z test_1            |
2023-12-14T12:24:53.1794946Z test_1            |       at Object.<anonymous> (src/a.test.ts:62:20)
2023-12-14T12:24:53.1841737Z test_1            |
2023-12-14T12:24:53.4683252Z test_1            | PASS src/b.test.ts
        "#;

        let failing_tests = JestLogParser::parse(logs).unwrap();

        assert_eq!(
            failing_tests,
            vec![CheckError {
                path: "src/b.test.ts".to_string(),
                lines: vec![
                    "FAIL src/b.test.ts".to_string(),
                    "  ● test › return test things".to_string(),
                    "".to_string(),
                    "    expect(received).toMatchObject(expected)".to_string(),
                    "".to_string(),
                    "    - Expected  - 1".to_string(),
                    "    + Received  + 0".to_string(),
                    "".to_string(),
                    "    @@ -17,9 +17,8 @@".to_string(),
                    "    -       \"testId\": undefined,".to_string(),
                    "          },".to_string(),
                    "        },".to_string(),
                    "      ]".to_string(),
                    "".to_string(),
                    "    > 62 |     expect(result).toMatchObject([".to_string(),
                    "         |                    ^".to_string(),
                    "".to_string(),
                    "      at Object.<anonymous> (src/a.test.ts:62:20)".to_string(),
                ],
            }]
        );
    }
}