diff --git a/README.md b/README.md index 734cd58..4fc0b7c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

- The Marv logo: Stylistic text that reads 'Marv' over a red rectangle containing a '-' and a green rectangle containing a '+' + Marv

-

Mutations Analysis, Review and Visualisation

+

Marv: Mutations Analysis, Review and Visualisation

Marv is a visualization and review tool for mutation testing. It provides a standardized results format and visualization across all [supported frameworks](#supported-frameworks). @@ -13,15 +13,15 @@ or even languages in one go. ## Table of Contents * [Supported Frameworks](#supported-frameworks) - * [Pitest Configuration](#pitest-configuration) - * [Decompilers](#decompilers) -* [Install & Build](#install--build) +* [Installation](#installation) + * [Build from source](#build-from-source) * [Libraries](#libraries) * [Usage](#usage) * [Gallery](#gallery) * [Export Format](#export-format) * [Mutations Format](#mutations-format) * [Reviews Format](#reviews-format) +* [Other](#other) ## Supported Frameworks @@ -33,106 +33,88 @@ A list of mutation testing frameworks that either are currently supported or wil * 🚧 In development * 🚫 Not currently supported -| Framework | language | Support | Marv Version | Required Libraries | Notes | -|------------------------------------------------------------------------------|------------|:-------:|:------------:|---------------------------------------------------------------------------------------------------------------------------------|-------------------------------------| -| [Mull](https://mull-project.com/) | C/C++ | 🚧 | | | | -| [Dextool Mutate](https://joakim-brannstrom.github.io/dextool/plugin/mutate/) | C/C++ | 🚫 | | | | -| [stryker-net](https://github.com/stryker-mutator/stryker-net) | C# | 🚫 | | | | -| [hcoles/pitest](https://github.com/hcoles/pitest) | Java | ✅️ | 1.0.0 | [vineflower-server](https://github.com/SecretSheppy/vineflower-server) (recommended)
or one of [alternatives](#decompilers) | See [Pitest configuration](#pitest) | -| [Major](https://mutation-testing.org/) | Java | 🚫 | | | | -| [stryker-js](https://github.com/stryker-mutator/stryker-js) | JavaScript | 🚫 | | | | -| [infection](https://github.com/infection/infection) | PHP | 🚫 | | | | -| [Cosmic Ray](https://github.com/sixty-north/cosmic-ray) | Python | 🚫 | | | | -| [MutPy](https://github.com/mutpy/mutpy) | Python | 🚫 | | | | -| [mutant](https://github.com/mbj/mutant) | Ruby | 🚫 | | | | -| [mutest-rs](https://github.com/zalanlevai/mutest-rs) | Rust | 🏆 | 1.0.0 | Native | | - -### Pitest Configuration - -Pitest must be run with the `-Dfeatures="+EXPORT"` flag which exports the mutated class files. This is required because -Marv will decompile these class files to construct each mutants replacement string. - -> [!NOTE] -> The replacement strings (inserted lines) that Marv produces are correct, however they are occasionally flanked by -> incorrectly formatted deleted lines due to formatting differences between the source code and decompiled class code. - -A new Marv Pitest configuration can be created by running the `marv init -f Pitest` command. - -#### Decompilers - -Marv has a range of decompiler options that can be used with to construct the Pitest mutant replacement strings. They -are listed below. - -> [!CAUTION] -> The `garlic` decompiler is currently unstable and using it could cause some mutants to be skipped due to a -> segmentation fault that occurs when running `garlic` on some class files. - -* [vineflower-server](https://github.com/SecretSheppy/vineflower-server) (recommended) -* [vineflower](https://github.com/Vineflower/vineflower) -* [garlic](https://github.com/neocanable/garlic) - -For installation location see [Installation - Libraries](#libraries) - -## Install & Build - -Marv can be quickly and easily installed with the `go` tool: - +| Framework | language | Support | Marv Version | Notes | +|------------------------------------------------------------------------------|------------|:-------:|:------------:|------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Mull](https://mull-project.com/) | C/C++ | 🏆 | 1.2.0 | Supported through [MTE](https://github.com/stryker-mutator/mutation-testing-elements) schema | +| [Dextool Mutate](https://joakim-brannstrom.github.io/dextool/plugin/mutate/) | C/C++ | 🚫 | | | +| [stryker-net](https://github.com/stryker-mutator/stryker-net) | C# | 🏆 | 1.2.0 | Supported through [MTE](https://github.com/stryker-mutator/mutation-testing-elements) schema | +| [hcoles/pitest](https://github.com/hcoles/pitest) | Java | ✅️ | 1.0.0 | See [Pitest configuration](fws/pitest/README.md) | +| [Major](https://mutation-testing.org/) | Java | 🚫 | | | +| [stryker-js](https://github.com/stryker-mutator/stryker-js) | JavaScript | 🏆 | 1.2.0 | Supported through [MTE](https://github.com/stryker-mutator/mutation-testing-elements) schema | +| [infection](https://github.com/infection/infection) | PHP | 🏆 | 1.2.0 | Supported through [MTE](https://github.com/stryker-mutator/mutation-testing-elements) schema, additionally see [infection readme](fws/infection/README.md) | +| [Cosmic Ray](https://github.com/sixty-north/cosmic-ray) | Python | 🚫 | | | +| [MutPy](https://github.com/mutpy/mutpy) | Python | 🚫 | | | +| [mutant](https://github.com/mbj/mutant) | Ruby | 🚫 | | | +| [mutest-rs](https://github.com/zalanlevai/mutest-rs) | Rust | 🏆 | 1.0.0 | | +| [strkyer4s](https://github.com/stryker-mutator/stryker4s) | Scala | 🏆 | 1.2.0 | Supported through [MTE](https://github.com/stryker-mutator/mutation-testing-elements) schema | + +## Installation + +Marv can be installed with the `go` tool. To use the installed executable, add the `GOPATH` environment variable +to your system path. For more information run `go help install`. ``` go install github.com/SecretSheppy/marv/cmd/marv@latest ``` -### Manual - -Clone the repository and run the command below that relates to the host operating system. To run the `marv` executable -from anywhere on the system, add the compiled executable to the system `PATH` variable. +### Build from source -* **Linux/MacOS:** `go build cmd/marv/marv.go -o marv` -* **Windows:** `go build cmd/marv/marv.go -o marv.exe` +Builds exactly as a normal go project would. See the [go.dev](https://go.dev/doc/tutorial/compile-install) tutorial +for more information. The target file to build is [`cmd/marv/main.go`](cmd/marv/main.go). +```cli +go build cmd/marv/main.go -o [output] +``` ### Libraries -Libraries can either be stored directly in the Marv install directory in the `lib` folder (this will need to be created, -as it does not exist by default) or in an external folder provided to Marv via the `MARV_LIB_PATH` environment variable. +If using a framework that requires external libraries, they will need to be set with the `MARV_LIB_PATH` environment +variable. The alternative to this is to put the library into the local directory where the Marv tool is being run. ## Usage -The output from `marv --help` is featured below and details how marv can be used. Marv defaults to the port `:8080`. - -```terminaloutput -Mutations Analysis, Review and Visualisation (Marv) is a tool that allows for efficient analysis and -review of mutations through visualisations - it can be used 'as is' or can be integrated into a -third party application to streamline review processes - -Usage: - marv [flags] - marv [command] - -Available Commands: - export exports framework output into standardised JSON - frameworks lists all installed frameworks - help Help about any command - init initialises a new default marv.yml file - -Flags: - -c, --config string .marv.yml file path - -h, --help help for marv - -m, --merge merges all frameworks output into one large json - -o, --output string specifies the output path - -p, --port string port to listen on - -v, --version version for marv +A simple guide of how to run Marv on a project for the first time. If at any point you need more information about +one of the Marv commands, try using the help command. +```cli +marv help [command] ``` -## Gallery +1. The first step is to ensure that Marv is correctly installed. If the Marv executable is correctly installed, running +the Marv version command will output a version number. If an error is printed, then it likely means you need to add +the Marv executable install location to your system path. +```cli +marv --version +``` + +2. Run the list command to see a `list` of all the frameworks that your installed version of Marv supports. +```cli +marv list +``` + +3. Then navigate to your project location and run the Marv `init` command with the list of frameworks you are using +(Marv framework names are case-sensitive, so make sure to copy them correctly from the output of the `list` command). +This will create a `.marv.yml` file in the directory that Marv was run in. The file will contain the default Marv +configuration as well as a blank configuration for each framework that was listed. +```cli +marv init -f [framework] -f [framework] ... +``` + +4. Now fill in the configurations for each framework. Where frameworks require paths, using paths relative to a repository +will allow you to safely commit the `.marv.yml` file for others to use. When finished with the configurations, simply +run `marv`. +```cli +marv +``` + +5. If you have correctly configured the frameworks then that is it! Provided you keep the `.marv.yml` configuration +file then all you have to do in future is simply run `marv`. -Screenshots of the Marv user interface showing results from: +## Gallery -* [mutest-rs](https://github.com/zalanlevai/mutest-rs) run on [alacritty](https://github.com/alacritty/alacritty) -* [hcoles/pitest](https://github.com/hcoles/pitest) run on [guava](https://github.com/google/guava) +Screenshots of the Marv user interface showing results from various frameworks. -| | | -|---------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| **Marv Results Overview:** Showing results from `mutest-rs` and `Pitest`
![](docs/marv_results_overview.png) | **Marv Pitest Results:** Showing `Pitest` mutants inline with a file from guava
![](docs/marv_pitest_guava.png) | -| **Marv mutest-rs Results:** Showing `mutest-rs` mutants inline with a file from alacritty
![](docs/marv_mutest_rs_alacritty.png) | **Marv Pitest Mutant:** Showing an isolated `Pitest` mutant inline with a file from guava
![](docs/marv_pitest_guava_mutant.png) | +| | | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Marv Results Overview:** Showing results from [stryker-net](https://github.com/stryker-mutator/stryker-net) run on itself
![](docs/marv_results_overview.png) | **Marv Pitest Results:** Showing [hcoles/pitest](https://github.com/hcoles/pitest) mutants inline with a file from [guava](https://github.com/google/guava)
![](docs/marv_pitest_guava.png) | +| **Marv mutest-rs Results:** Showing [mutest-rs](https://github.com/zalanlevai/mutest-rs) mutants inline with a file from [alacritty](https://github.com/alacritty/alacritty)
![](docs/marv_mutest_rs_alacritty.png) | **Marv Infection PHP Mutant:** Showing an isolated [Infection](https://github.com/infection/infection) mutant inline with a file from its own source
![](docs/marv_infection_php_mutant.png) | ## Export Format @@ -141,8 +123,8 @@ By using the `-m` or `--merge` flags, the results from all frameworks are merged ### Mutations Format -The mutations format follows the internal structures defined in [`internal/mutations`](internal/mutations). The basic -structure is `file path` > `conflict region` > `mutation`. Marv uses `conflict regions` or internally called +The mutations format follows the internal structures defined in [`internal/mutations`](internal/mutations/mutations.go). +The basic structure is `file path` > `conflict region` > `mutation`. Marv uses `conflict regions` or internally called `mutations.Conflict` to wrap all mutations that would conflict with each other if just rendered inline due to overlaps. Any `ID` field is a UUID created by Marv. Where frameworks create mutant identifiers, they are stored against the mutant @@ -180,6 +162,9 @@ as `FrameworkMutantID`. ### Reviews Format +Reviews are exported against the corresponding mutations Marv Mutation ID and their Framework Mutation +ID (if applicable). The review structure is defined in [`internal/review`](internal/review/review.go). + ```json [ { @@ -189,4 +174,8 @@ as `FrameworkMutantID`. "Review": "An example review" } ] -``` \ No newline at end of file +``` + +## Other + +[Icon Licenses (icons.md)](icons.md) \ No newline at end of file diff --git a/docs/marv_infection_php_mutant.png b/docs/marv_infection_php_mutant.png new file mode 100644 index 0000000..46b1f66 Binary files /dev/null and b/docs/marv_infection_php_mutant.png differ diff --git a/docs/marv_pitest_guava.png b/docs/marv_pitest_guava.png index c564a65..b593c0e 100644 Binary files a/docs/marv_pitest_guava.png and b/docs/marv_pitest_guava.png differ diff --git a/docs/marv_pitest_guava_mutant.png b/docs/marv_pitest_guava_mutant.png deleted file mode 100644 index 84b26d8..0000000 Binary files a/docs/marv_pitest_guava_mutant.png and /dev/null differ diff --git a/docs/marv_results_overview.png b/docs/marv_results_overview.png index 5478831..06c4b76 100644 Binary files a/docs/marv_results_overview.png and b/docs/marv_results_overview.png differ diff --git a/fwlib/fwlib.go b/fwlib/fwlib.go index 8aa93f6..def951b 100644 --- a/fwlib/fwlib.go +++ b/fwlib/fwlib.go @@ -1,8 +1,12 @@ package fwlib import ( + "fmt" + "os" + "github.com/SecretSheppy/marv/internal/languages" "github.com/SecretSheppy/marv/internal/mutations" + "github.com/schollz/progressbar/v3" ) type Meta struct { @@ -46,3 +50,16 @@ type Framework interface { // ReadLines returns the lines of the specified file ReadLines(file string) ([]string, error) } + +func NewProgressbar(length int, desc string) *progressbar.ProgressBar { + return progressbar.NewOptions( + length, + progressbar.OptionSetWriter(os.Stdout), + progressbar.OptionSetDescription(desc), + progressbar.OptionSetRenderBlankState(true)) +} + +func FinishProgressbar(bar *progressbar.ProgressBar) { + bar.Finish() + fmt.Println() +} diff --git a/fws/fws.go b/fws/fws.go index 1952179..0eccc55 100644 --- a/fws/fws.go +++ b/fws/fws.go @@ -2,14 +2,24 @@ package fws import ( "github.com/SecretSheppy/marv/fwlib" + "github.com/SecretSheppy/marv/fws/infection" + "github.com/SecretSheppy/marv/fws/mull" "github.com/SecretSheppy/marv/fws/mutest_rs" "github.com/SecretSheppy/marv/fws/pitest" + "github.com/SecretSheppy/marv/fws/stryker4s" + "github.com/SecretSheppy/marv/fws/stryker_js" + "github.com/SecretSheppy/marv/fws/stryker_net" ) func Frameworks() []fwlib.Framework { return []fwlib.Framework{ + infection.NewInfection(), + mull.NewMull(), mutest_rs.NewMutestRS(), pitest.NewPitest(), + stryker4s.NewStryker4s(), + stryker_js.NewStrykerJS(), + stryker_net.NewStrykerNet(), } } diff --git a/fws/infection/README.md b/fws/infection/README.md new file mode 100644 index 0000000..bcaaa37 --- /dev/null +++ b/fws/infection/README.md @@ -0,0 +1,49 @@ +## Important Information About Infection Support in Marv + +> [!NOTE] +> See [infection example MTE report](https://dashboard.stryker-mutator.io/reports/github.com/infection/infection/master#mutant) for the mutant dataset used to in examples. + +The Infection framework often exports what Marv views as "broken" mutations. This is because the value of the +mutations end column exceeds the length of the end line, for example: + +```json +{ + "id": "adf6cacd35a4867054da054ee6a6b4a0", + "mutatorName": "ConcatOperandRemoval", + "replacement": "'Cannot access to the command application if the command has not been ',", + "description": "Removes an operand from a string concatenation.\n\n```php\n$x = 'foo' . 'bar';\n```\n\nWill be mutated to:\n\n```php\n$x = 'foo';\n```\n\nAnd:\n\n```php\n$x = 'bar';\n", + "location": { + "start": { + "line": 61, + "column": 13 + }, + "end": { + "line": 62, + "column": 84 + } + }, + "status": "Survived" +} +``` + +**Caption:** Mutant taken from [`Command/BaseCommand.php`](https://dashboard.stryker-mutator.io/reports/github.com/infection/infection/master#mutant/Command/BaseCommand.php) that Marv would view as "broken". + +If Marv tries to render a "broken" mutation, like the one shown above, it will panic and fail to render the file. The +reason this occurs in Marv and not in [Stryker Mutation Testing Elements (MTE)](https://github.com/stryker-mutator/mutation-testing-elements/tree/master) +is because of the different ways the two system parse files. MTE never splits the source file into lines, and instead +makes indexes of where newline characters occur, and uses those to read and replace bits of the source. This means that +when a "broken" mutation is shown by MTE, it will not fail to render the file. However, it will render the mutation +incorrectly, as shown below. + +![MTE Formatting Error Example](docs/mte_incorrect_formatting.png) + +As previously stated, if Marv tried to render the above mutation as is, it would panic and fail to show the file. This +is because Marv does split the source file into a slice of lines, and then performs the replacement substitution on +the individual lines. + +To correct for this issue with Infection, the [Marv Infection Framework](infection.go) instance will iterate over all +mutations, and for any mutations where `mutation.End.Char > len(line)`, it then sets `mutation.End.Char = len(line)`. +In practise, this seems to solve the formatting issues for most cases. The Marv formatted version of the same mutation +is shown below. + +![Marv Formatting Correction Example](docs/marv_formatting_correction.png) \ No newline at end of file diff --git a/fws/infection/docs/marv_formatting_correction.png b/fws/infection/docs/marv_formatting_correction.png new file mode 100644 index 0000000..5515c09 Binary files /dev/null and b/fws/infection/docs/marv_formatting_correction.png differ diff --git a/fws/infection/docs/mte_incorrect_formatting.png b/fws/infection/docs/mte_incorrect_formatting.png new file mode 100644 index 0000000..4da93fa Binary files /dev/null and b/fws/infection/docs/mte_incorrect_formatting.png differ diff --git a/fws/infection/infection.go b/fws/infection/infection.go new file mode 100644 index 0000000..bb19fa7 --- /dev/null +++ b/fws/infection/infection.go @@ -0,0 +1,96 @@ +package infection + +import ( + "github.com/SecretSheppy/marv/fwlib" + "github.com/SecretSheppy/marv/internal/languages" + "github.com/SecretSheppy/marv/internal/mtelib" + "github.com/SecretSheppy/marv/internal/mutations" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +var meta = fwlib.Meta{ + Name: "infection", + Language: languages.Php, + URL: "https://https://infection.github.io/", +} + +type YamlConfig struct { + MTEJson string `yaml:"mte-json"` +} + +type YamlWrapper struct { + Cfg *YamlConfig `yaml:"infection"` +} + +func (y *YamlWrapper) Init() interface{} { + return &YamlWrapper{Cfg: &YamlConfig{}} +} + +func (y *YamlWrapper) Load(yml []byte) (bool, error) { + if err := yaml.Unmarshal(yml, y); err != nil { + return false, err + } + if y.Cfg == nil { + return false, nil + } + return y.Cfg.MTEJson != "", nil +} + +type Infection struct { + yml *YamlWrapper + mte *mtelib.MTE +} + +func NewInfection() *Infection { + return &Infection{yml: &YamlWrapper{}} +} + +func (i *Infection) Meta() *fwlib.Meta { + return &meta +} + +func (i *Infection) Yaml() fwlib.FWConfig { + return i.yml +} + +func (i *Infection) LoadResults() error { + log.Info().Msgf("%s - loading results", i.Meta().Name) + var err error + i.mte, err = mtelib.NewMTE(i.yml.Cfg.MTEJson) + return err +} + +func (i *Infection) TransformResults() error { + log.Info().Msgf("%s - transforming results", i.Meta().Name) + + bar := fwlib.NewProgressbar(i.mte.RawMutationsCount(), "transforming") + i.mte.Transform(bar) + fwlib.FinishProgressbar(bar) + + i.correctLineLengthOverhangs() + return nil +} + +// see infection/README.md as to why this method is necessary and for visual examples of it in practise. +func (i *Infection) correctLineLengthOverhangs() { + for file, conflicts := range i.mte.Mutations() { + lines := i.mte.ReadLines(file) + for _, conflict := range conflicts { + for _, mutation := range conflict.Mutations { + endLineLength := len(lines[mutation.End.Line]) + if mutation.End.Char > endLineLength { + mutation.End.Char = endLineLength + } + } + } + } +} + +func (i *Infection) Mutations() mutations.Mutations { + return i.mte.Mutations() +} + +func (i *Infection) ReadLines(file string) ([]string, error) { + return i.mte.ReadLines(file), nil +} diff --git a/fws/mull/mull.go b/fws/mull/mull.go new file mode 100644 index 0000000..a0653e6 --- /dev/null +++ b/fws/mull/mull.go @@ -0,0 +1,177 @@ +package mull + +import ( + "fmt" + "regexp" + "slices" + + "github.com/SecretSheppy/marv/fwlib" + "github.com/SecretSheppy/marv/internal/languages" + "github.com/SecretSheppy/marv/internal/mtelib" + "github.com/SecretSheppy/marv/internal/mutations" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +var ( + meta = fwlib.Meta{ + Name: "Mull", + Language: languages.Cpp, + URL: "https://mull-project.com/", + } + function = regexp.MustCompile(`\s*([A-Za-z_0-9]*)\s*\(`) + + // cpp keywords according to https://en.cppreference.com/cpp/keyword + cppKeyWords = []string{ + "alignas", "alignof", "and", "and_eq", "asm", "atomic_cancel", "atomic_commit", "atomic_noexcept", "auto", + "bitand", "bitor", "bool", "break", "case", "catch", "char", "char8_t", "char16_t", "char32_t", "class", + "compl", "concept", "const", "consteval", "constexpr", "constinit", "const_cast", "continue", "contract_assert", + "co_await", "co_return", "co_yield", "decltype", "default", "delete", "do", "double", "dynamic_cast", "else", + "enum", "explicit", "export", "extern", "false", "float", "for", "friend", "goto", "if", "inline", "int", + "long", "mutable", "namespace", "new", "noexcept", "not", "not_eq", "nullptr", "operator", "or", "or_eq", + "private", "protected", "public", "reflexpr", "register", "reinterpret_cast", "requires", "return", "short", + "signed", "sizeof", "static", "static_assert", "static_cast", "struct", "switch", "synchronized", "template", + "this", "thread_local", "throw", "true", "try", "typedef", "typeid", "typename", "union", "unsigned", "using", + "virtual", "void", "volatile", "wchar_t", "while", "xor", "xor_eq", + } +) + +type YamlConfig struct { + MTEJson string `yaml:"mte-json"` +} + +type YamlWrapper struct { + Cfg *YamlConfig `yaml:"mull"` +} + +func (y *YamlWrapper) Init() interface{} { + return &YamlWrapper{Cfg: &YamlConfig{}} +} + +func (y *YamlWrapper) Load(yml []byte) (bool, error) { + if err := yaml.Unmarshal(yml, y); err != nil { + return false, err + } + if y.Cfg == nil { + return false, nil + } + return y.Cfg.MTEJson != "", nil +} + +type Mull struct { + yml *YamlWrapper + mte *mtelib.MTE +} + +func NewMull() *Mull { + return &Mull{yml: &YamlWrapper{}} +} + +func (m *Mull) Meta() *fwlib.Meta { + return &meta +} + +func (m *Mull) Yaml() fwlib.FWConfig { + return m.yml +} + +func (m *Mull) LoadResults() error { + log.Info().Msgf("%s - loading results", m.Meta().Name) + var err error + m.mte, err = mtelib.NewMTE(m.yml.Cfg.MTEJson) + return err +} + +func (m *Mull) TransformResults() error { + log.Info().Msgf("%s - transforming results", m.Meta().Name) + + bar := fwlib.NewProgressbar(m.mte.RawMutationsCount(), "transforming") + m.mte.Transform(bar) + fwlib.FinishProgressbar(bar) + + fixed := 0 + for file, conflicts := range m.mte.Mutations() { + lines := m.mte.ReadLines(file) + for _, conflict := range conflicts { + for _, mutation := range conflict.Mutations { + if mutation.IsBroken() { + fixed += attemptBrokenMutationFix(mutation) + conflict.ResizeToInclude(mutation) + } + m.generateDescription(lines, mutation) + } + } + } + log.Info().Msgf("%s - fixed %d broken mutations", m.Meta().Name, fixed) + + return nil +} + +// generates a description based off of the descriptions give on the supported mutation operators page of the mull +// documentation: https://mull.readthedocs.io/en/latest/SupportedMutations.html. +func (m *Mull) generateDescription(lines []string, mutation *mutations.Mutation) { + switch mutation.Operation { + case "cxx_remove_void_call": + mutation.Description = fmt.Sprintf("Removed call to void function `%s`", getFuncName(lines, mutation)) + case "cxx_replace_scalar_call": + mutation.Description = fmt.Sprintf("Replaced call to function `%s` with `42`", getFuncName(lines, mutation)) + case "negate_mutator": + mutation.Description = "Negated conditionals" + case "scalar_value_mutator": + mutation.Description = "Replaced zeros with `42` and non-zeros with `0`" + default: + line := lines[mutation.Start.Line] + endChar := len(line) - 1 + if mutation.Start.Line == mutation.End.Line { + endChar = mutation.End.Char + } + original := line[mutation.Start.Char:endChar] + if mutation.Replacement != "" { + mutation.Description = fmt.Sprintf("Replaced `%s` with `%s`", original, mutation.Replacement) + } else { + mutation.Description = fmt.Sprintf("Removed `%s`", original) + } + } +} + +func attemptBrokenMutationFix(mutation *mutations.Mutation) int { + switch mutation.Operation { + case "cxx_assign_const", "cxx_init_const", "cxx_remove_void_call", "cxx_replace_scalar_void", "negate_mutator": + // Operators marv does not currently fix. + return 0 + case "cxx_bitwise_not_to_noop", "cxx_minus_to_noop", "cxx_post_dec_to_post_inc", "cxx_pre_dec_to_pre_inc", "cxx_remove_negation", "cxx_gt_to_ge", "cxx_gt_to_le", "cxx_lt_to_ge", "cxx_lt_to_le": + // Operators that replace a source string length of 1 (~, !, -) + mutation.End.Line = mutation.Start.Line + mutation.End.Char = mutation.Start.Char + 1 + case "cxx_ge_to_gt", "cxx_ge_to_lt", "cxx_le_to_gt", "cxx_le_to_lt", "cxx_post_inc_to_post_dec", "cxx_pre_inc_to_pre_dec": + // Operators that replace a source string length of 2 (==, <=, /=, ...) + mutation.End.Line = mutation.Start.Line + mutation.End.Char = mutation.Start.Char + 2 + default: + // Operators that replace a source string of length equal to its replacement string + mutation.End.Line = mutation.Start.Line + mutation.End.Char = mutation.Start.Char + len(mutation.Replacement) + } + return 1 +} + +func getFuncName(lines []string, mutation *mutations.Mutation) string { + match := function.FindAllStringSubmatch(lines[mutation.Start.Line], -1) + funcStr := "??" + for _, str := range match { + // NOTE: takes first non keyword in the replacements string that matches the regex as the function name. + if !slices.Contains(cppKeyWords, str[1]) && str[1] != "" { + funcStr = str[1] + break + } + } + return funcStr +} + +func (m *Mull) Mutations() mutations.Mutations { + return m.mte.Mutations() +} + +func (m *Mull) ReadLines(file string) ([]string, error) { + return m.mte.ReadLines(file), nil +} diff --git a/fws/mutest_rs/mutest_rs.go b/fws/mutest_rs/mutest_rs.go index f23bb56..859223c 100644 --- a/fws/mutest_rs/mutest_rs.go +++ b/fws/mutest_rs/mutest_rs.go @@ -3,7 +3,6 @@ package mutest_rs import ( "encoding/json" "errors" - "fmt" "os" "path" "strconv" @@ -134,18 +133,12 @@ func (m *MutestRS) LoadResults() error { func (m *MutestRS) TransformResults() error { log.Info().Msgf("%s - transforming results", m.Meta().Name) - bar := progressbar.NewOptions( - len(m.muts.Mutations), - progressbar.OptionSetWriter(os.Stdout), - progressbar.OptionSetDescription("transforming"), - progressbar.OptionSetRenderBlankState(true)) - + bar := fwlib.NewProgressbar(len(m.muts.Mutations), "transforming") ms, err := m.transformResults(bar) if err != nil { return err } - bar.Finish() - fmt.Println() + fwlib.FinishProgressbar(bar) m.ms = ms return nil diff --git a/fws/pitest/README.md b/fws/pitest/README.md new file mode 100644 index 0000000..8a63145 --- /dev/null +++ b/fws/pitest/README.md @@ -0,0 +1,29 @@ +### Pitest + +Pitest must be run with the `-Dfeatures="+EXPORT"` flag which exports the mutated class files. This is required because +Marv will decompile these class files to construct each mutants replacement string. + +```cli +mvn org.pitest:pitest-maven:mutationCoverage -Dfeatures="+EXPORT +``` + +> [!NOTE] +> The replacement strings (inserted lines) that Marv produces are correct, however they are occasionally flanked by +> incorrectly formatted deleted lines due to formatting differences between the source code and decompiled class code. + +A new Marv Pitest configuration can be created by running the `marv init -f Pitest` command. + +#### Decompilers + +Marv has a range of decompiler options that can be used with to construct the Pitest mutant replacement strings. They +are listed below. + +> [!CAUTION] +> The `garlic` decompiler is currently unstable and using it could cause some mutants to be skipped due to a +> segmentation fault that occurs when running `garlic` on some class files. + +* [vineflower-server](https://github.com/SecretSheppy/vineflower-server) (recommended) +* [vineflower](https://github.com/Vineflower/vineflower) +* [garlic](https://github.com/neocanable/garlic) + +For installation location see [Installation - Libraries](../../README.md#libraries) diff --git a/fws/pitest/pitest.go b/fws/pitest/pitest.go index 5b75361..557954c 100644 --- a/fws/pitest/pitest.go +++ b/fws/pitest/pitest.go @@ -13,7 +13,6 @@ import ( "github.com/SecretSheppy/marv/internal/mutations" "github.com/SecretSheppy/marv/pkg/fio" "github.com/rs/zerolog/log" - "github.com/schollz/progressbar/v3" "gopkg.in/yaml.v3" ) @@ -147,45 +146,26 @@ func (p *Pitest) LoadResults() error { func (p *Pitest) TransformResults() error { log.Info().Msgf("%s - transforming results", p.Meta().Name) - groupBar := progressbar.NewOptions( - len(p.muts), - progressbar.OptionSetWriter(os.Stdout), - progressbar.OptionSetDescription("[1/3] grouping"), - progressbar.OptionSetRenderBlankState(true), - progressbar.OptionShowCount()) + groupBar := fwlib.NewProgressbar(len(p.muts), "[1/3] grouping") fileMutations := p.groupMutants(groupBar) - groupBar.Finish() - fmt.Println() - - indexBar := progressbar.NewOptions( - len(p.muts), - progressbar.OptionSetWriter(os.Stdout), - progressbar.OptionSetDescription("[2/3] indexing"), - progressbar.OptionSetRenderBlankState(true), - progressbar.OptionShowCount()) - err := p.indexMutants(fileMutations, indexBar) - if err != nil { + fwlib.FinishProgressbar(groupBar) + + indexBar := fwlib.NewProgressbar(len(p.muts), "[2/3] indexing") + if err := p.indexMutants(fileMutations, indexBar); err != nil { return err } - indexBar.Finish() - fmt.Println() + fwlib.FinishProgressbar(indexBar) log.Info().Msgf("%s - using %s", p.Meta().Name, p.dcomp) if err := p.dcomp.Setup(); err != nil { return err } - transformBar := progressbar.NewOptions( - len(fileMutations), - progressbar.OptionSetWriter(os.Stdout), - progressbar.OptionSetDescription("[3/3] transforming"), - progressbar.OptionSetRenderBlankState(true), - progressbar.OptionShowCount()) + transformBar := fwlib.NewProgressbar(len(fileMutations), "[3/3] transforming") var errs []error p.ms, errs = transform(p, fileMutations, transformBar) // NOTE: perform stdout cleanup before printing errors. - transformBar.Finish() - fmt.Println() + fwlib.FinishProgressbar(transformBar) if len(errs) > 0 { for _, err := range errs { err.(*transformError).log() diff --git a/fws/stryker4s/stryker4s.go b/fws/stryker4s/stryker4s.go new file mode 100644 index 0000000..a90982f --- /dev/null +++ b/fws/stryker4s/stryker4s.go @@ -0,0 +1,80 @@ +package stryker4s + +import ( + "github.com/SecretSheppy/marv/fwlib" + "github.com/SecretSheppy/marv/internal/languages" + "github.com/SecretSheppy/marv/internal/mtelib" + "github.com/SecretSheppy/marv/internal/mutations" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +var meta = fwlib.Meta{ + Name: "stryker4s", + Language: languages.Scala, + URL: "https://github.com/stryker-mutator/stryker4s", +} + +type YamlConfig struct { + MTEJson string `yaml:"mte-json"` +} + +type YamlWrapper struct { + Cfg *YamlConfig `yaml:"stryker4s"` +} + +func (y *YamlWrapper) Init() interface{} { + return &YamlWrapper{Cfg: &YamlConfig{}} +} + +func (y *YamlWrapper) Load(yml []byte) (bool, error) { + if err := yaml.Unmarshal(yml, y); err != nil { + return false, err + } + if y.Cfg == nil { + return false, nil + } + return y.Cfg.MTEJson != "", nil +} + +type Stryker4s struct { + yml *YamlWrapper + mte *mtelib.MTE +} + +func NewStryker4s() *Stryker4s { + return &Stryker4s{yml: &YamlWrapper{}} +} + +func (s *Stryker4s) Meta() *fwlib.Meta { + return &meta +} + +func (s *Stryker4s) Yaml() fwlib.FWConfig { + return s.yml +} + +func (s *Stryker4s) LoadResults() error { + log.Info().Msgf("%s - loading results", s.Meta().Name) + var err error + s.mte, err = mtelib.NewMTE(s.yml.Cfg.MTEJson) + return err +} + +func (s *Stryker4s) TransformResults() error { + log.Info().Msgf("%s - transforming results", s.Meta().Name) + + bar := fwlib.NewProgressbar(s.mte.RawMutationsCount(), "transforming") + s.mte.Transform(bar) + fwlib.FinishProgressbar(bar) + + return nil +} + +func (s *Stryker4s) Mutations() mutations.Mutations { + return s.mte.Mutations() +} + +func (s *Stryker4s) ReadLines(file string) ([]string, error) { + return s.mte.ReadLines(file), nil +} diff --git a/fws/stryker_js/stryker_javascript.go b/fws/stryker_js/stryker_javascript.go new file mode 100644 index 0000000..90112db --- /dev/null +++ b/fws/stryker_js/stryker_javascript.go @@ -0,0 +1,84 @@ +package stryker_js + +import ( + "github.com/SecretSheppy/marv/fwlib" + "github.com/SecretSheppy/marv/internal/languages" + "github.com/SecretSheppy/marv/internal/mtelib" + "github.com/SecretSheppy/marv/internal/mutations" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +var meta = fwlib.Meta{ + Name: "stryker-js", + Language: languages.JavaScript, + URL: "https://github.com/stryker-mutator/stryker-net", +} + +type YamlConfig struct { + MTEJson string `yaml:"mte-json"` + Language string `yaml:"language"` +} + +type YamlWrapper struct { + Cfg *YamlConfig `yaml:"stryker-js"` +} + +func (y *YamlWrapper) Init() interface{} { + return &YamlWrapper{Cfg: &YamlConfig{}} +} + +func (y *YamlWrapper) Load(yml []byte) (bool, error) { + if err := yaml.Unmarshal(yml, y); err != nil { + return false, err + } + if y.Cfg == nil { + return false, nil + } + if y.Cfg.Language == "ts" || y.Cfg.Language == "typescript" { + meta.Language = languages.TypeScript + } + return y.Cfg.MTEJson != "" && y.Cfg.Language != "", nil +} + +type StrykerJS struct { + yml *YamlWrapper + mte *mtelib.MTE +} + +func NewStrykerJS() *StrykerJS { + return &StrykerJS{yml: &YamlWrapper{}} +} + +func (s *StrykerJS) Meta() *fwlib.Meta { + return &meta +} + +func (s *StrykerJS) Yaml() fwlib.FWConfig { + return s.yml +} + +func (s *StrykerJS) LoadResults() error { + log.Info().Msgf("%s - loading results", s.Meta().Name) + var err error + s.mte, err = mtelib.NewMTE(s.yml.Cfg.MTEJson) + return err +} + +func (s *StrykerJS) TransformResults() error { + log.Info().Msgf("%s - transforming results", s.Meta().Name) + + bar := fwlib.NewProgressbar(s.mte.RawMutationsCount(), "transforming") + s.mte.Transform(bar) + fwlib.FinishProgressbar(bar) + + return nil +} + +func (s *StrykerJS) Mutations() mutations.Mutations { + return s.mte.Mutations() +} + +func (s *StrykerJS) ReadLines(file string) ([]string, error) { + return s.mte.ReadLines(file), nil +} diff --git a/fws/stryker_net/stryker_net.go b/fws/stryker_net/stryker_net.go new file mode 100644 index 0000000..3d9c464 --- /dev/null +++ b/fws/stryker_net/stryker_net.go @@ -0,0 +1,80 @@ +package stryker_net + +import ( + "github.com/SecretSheppy/marv/fwlib" + "github.com/SecretSheppy/marv/internal/languages" + "github.com/SecretSheppy/marv/internal/mtelib" + "github.com/SecretSheppy/marv/internal/mutations" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +var meta = fwlib.Meta{ + Name: "stryker-net", + Language: languages.CSharp, + URL: "https://github.com/stryker-mutator/stryker-net", +} + +type YamlConfig struct { + MTEJson string `yaml:"mte-json"` +} + +type YamlWrapper struct { + Cfg *YamlConfig `yaml:"stryker-net"` +} + +func (y *YamlWrapper) Init() interface{} { + return &YamlWrapper{Cfg: &YamlConfig{}} +} + +func (y *YamlWrapper) Load(yml []byte) (bool, error) { + if err := yaml.Unmarshal(yml, y); err != nil { + return false, err + } + if y.Cfg == nil { + return false, nil + } + return y.Cfg.MTEJson != "", nil +} + +type StrykerNet struct { + yml *YamlWrapper + mte *mtelib.MTE +} + +func NewStrykerNet() *StrykerNet { + return &StrykerNet{yml: &YamlWrapper{}} +} + +func (s *StrykerNet) Meta() *fwlib.Meta { + return &meta +} + +func (s *StrykerNet) Yaml() fwlib.FWConfig { + return s.yml +} + +func (s *StrykerNet) LoadResults() error { + log.Info().Msgf("%s - loading results", s.Meta().Name) + var err error + s.mte, err = mtelib.NewMTE(s.yml.Cfg.MTEJson) + return err +} + +func (s *StrykerNet) TransformResults() error { + log.Info().Msgf("%s - transforming results", s.Meta().Name) + + bar := fwlib.NewProgressbar(s.mte.RawMutationsCount(), "transforming") + s.mte.Transform(bar) + fwlib.FinishProgressbar(bar) + + return nil +} + +func (s *StrykerNet) Mutations() mutations.Mutations { + return s.mte.Mutations() +} + +func (s *StrykerNet) ReadLines(file string) ([]string, error) { + return s.mte.ReadLines(file), nil +} diff --git a/icons.md b/icons.md new file mode 100644 index 0000000..d5fdcb1 --- /dev/null +++ b/icons.md @@ -0,0 +1,25 @@ +## Language Icon Licenses + +* C++ [Terms of Use](https://isocpp.org/home/terms-of-use) +* Dotnet [CC0 1.0 Universal license](https://github.com/dotnet/brand) +* Go [Fair Use](https://go.dev/brand) +* Java (Duke) [BSD License](https://www.oracle.com/a/ocom/docs/java-licensing-logo-guidelines-1908204.pdf) +* JavaScript [MIT](https://github.com/voodootikigod/logo.js/) +* Php [CC BY-SA 4.0](https://www.php.net/download-logos.php) +* Rust [CC-BY](https://github.com/rust-lang/rust-artwork/blob/master/logo/README.md) +* TypeScript [Branding Guidlines](https://www.typescriptlang.org/branding/) + +For other languages see [Custom Language Icons](#custom-language-icons) + +## Font Awesome + +All Font Awesome svg icons are used under the [Font Awesome Free License](https://fontawesome.com/license/free). + +## Marv UI Icons + +Marv contains a swathe of icons that have been created specifically for its UI. These icons are available for use +under the same [BSD 3-Clause License](LICENSE) that Marv is. + +### Custom Language Icons + +* [Scala Icon](web/static/languages/marv_scala_icon.png) is custom-made due to lack of available license for official Scala logo. diff --git a/internal/cmds/list.go b/internal/cmds/list.go index 0ccd17e..e4e47da 100644 --- a/internal/cmds/list.go +++ b/internal/cmds/list.go @@ -11,8 +11,8 @@ import ( ) var listCmd = &cobra.Command{ - Use: "frameworks", - Aliases: []string{"list"}, + Use: "list", + Aliases: []string{"frameworks"}, Short: "lists all installed frameworks", Long: "lists all installed frameworks by name", Run: func(cmd *cobra.Command, args []string) { diff --git a/internal/cmds/root.go b/internal/cmds/root.go index 1d6bf07..e0ca643 100644 --- a/internal/cmds/root.go +++ b/internal/cmds/root.go @@ -86,7 +86,7 @@ func mergeFlagsWithConfig(cfg *config.Config) error { return nil } -func transformMutations(activeFws []fwlib.Framework) error { +func transformMutations(conf *config.Config, activeFws []fwlib.Framework) error { for _, fw := range activeFws { if decompiling, ok := fw.(fwlib.Decompiling); ok { decompiling.SetDecompiler() @@ -96,11 +96,31 @@ func transformMutations(activeFws []fwlib.Framework) error { return err } + if err := extractBrokenMutations(conf, fw); err != nil { + return err + } + + fw.Mutations().MergeConflicting() fw.Mutations().GenerateIDs() } return nil } +// extracts and removes broken mutations (se IsBroken method on mutations.Mutation) +func extractBrokenMutations(conf *config.Config, fw fwlib.Framework) error { + broken := fw.Mutations().ExtractBrokenMutations() + if len(broken) > 0 { + out := path.Join(conf.Marv.Output.Path, fw.Meta().Name+"-broken.json") + marshal, err := json.Marshal(broken) + if err != nil { + return err + } + os.WriteFile(out, marshal, 0644) + log.Warn().Msgf("%s - extracted %d broken mutations and dumped them in %s", fw.Meta().Name, len(broken), out) + } + return nil +} + func export(conf *config.Config, activeFws []fwlib.Framework) error { for _, fw := range activeFws { marshal, err := json.Marshal(fw.Mutations()) @@ -186,7 +206,7 @@ func exportCommand() (*config.Config, []fwlib.Framework) { os.Exit(1) } - if err := transformMutations(activeFws); err != nil { + if err := transformMutations(conf, activeFws); err != nil { log.Fatal().Err(err).Msg("Failed to transform results") os.Exit(1) } diff --git a/internal/html/code.go b/internal/html/code.go index 6c9bdc9..6cc6664 100644 --- a/internal/html/code.go +++ b/internal/html/code.go @@ -263,7 +263,7 @@ func (r *codeRenderer) highlightMutationParts(pre, diff, post string) ([]string, func (r *codeRenderer) renderMutationHeader(buff *bytes.Buffer, m *mutations.Mutation) { buff.WriteString("
") buff.WriteString(m.Status.IconWithText()) - buff.WriteString(fmt.Sprintf("

%s

", html.EscapeString(m.Description))) + buff.WriteString(fmt.Sprintf("

%s

", html.EscapeString(m.GetDescription()))) buff.WriteString("
") buff.WriteString("") buff.WriteString(fmt.Sprintf("%.7s", m.ID, r.framework, r.file, m.ID, m.ID, m.ID)) diff --git a/internal/languages/languages.go b/internal/languages/languages.go index 7c1a8d7..685d936 100644 --- a/internal/languages/languages.go +++ b/internal/languages/languages.go @@ -1,9 +1,15 @@ package languages var ( - Go = &Language{name: "Go", ext: "go", icon: "/resources/languages/golang-brands-solid.svg"} - Java = &Language{name: "Java", ext: "java", icon: "/resources/languages/java-brands-solid.svg"} - Rust = &Language{name: "Rust", ext: "rs", icon: "/resources/languages/rust-brands-solid.svg"} + CSharp = &Language{name: "C#", ext: "cs", icon: "/resources/languages/dotnet_logo.svg"} + Cpp = &Language{name: "C++", ext: "cpp", icon: "/resources/languages/cpp_logo.svg"} + Go = &Language{name: "Go", ext: "go", icon: "/resources/languages/go_logo_blue.svg"} + Java = &Language{name: "Java", ext: "java", icon: "/resources/languages/java_duke_icon.svg"} + JavaScript = &Language{name: "JavaScript", ext: "js", icon: "/resources/languages/js_logo.png"} + Php = &Language{name: "PHP", ext: "php", icon: "/resources/languages/php_logo.svg"} + Rust = &Language{name: "Rust", ext: "rs", icon: "/resources/languages/rust_logo.svg"} + Scala = &Language{name: "Scala", ext: "scala", icon: "/resources/languages/marv_scala_icon.png"} + TypeScript = &Language{name: "TypeScript", ext: "ts", icon: "/resources/languages/ts_log_128.svg"} ) type Language struct { diff --git a/internal/marvinfo/marvinfo.go b/internal/marvinfo/marvinfo.go index 9542fe0..d4e835a 100644 --- a/internal/marvinfo/marvinfo.go +++ b/internal/marvinfo/marvinfo.go @@ -1,5 +1,5 @@ package marvinfo func Version() string { - return "1.1.0" + return "1.2.0" } diff --git a/internal/mtelib/mtelib.go b/internal/mtelib/mtelib.go new file mode 100644 index 0000000..afadb2a --- /dev/null +++ b/internal/mtelib/mtelib.go @@ -0,0 +1,185 @@ +package mtelib + +import ( + "encoding/json" + "os" + "sort" + "strings" + + "github.com/SecretSheppy/marv/internal/mutations" + "github.com/schollz/progressbar/v3" +) + +// Mutation Testing Elements Library +// +// A library that provides structs and methods to unmarshal and marshal mutations from the Mutation Testing Elements +// JSON format. Built off of mutation testing report schema version 2.0.1 + +// MutationTestResult represents the main Mutation Testing Elements JSON file. +type MutationTestResult struct { + SchemaVersion string `json:"schemaVersion"` + Files FileResultDictionary `json:"files"` +} + +func (m *MutationTestResult) CountMutations() int { + count := 0 + for _, file := range m.Files { + count += len(file.Mutants) + } + return count +} + +// FileResultDictionary is a dictionary that stores FileResults against their string file paths. +type FileResultDictionary map[string]FileResult + +// FileResult contains the files language, mutants and unedited source code. +type FileResult struct { + Language string `json:"language"` + Mutants []MutantResult `json:"mutants"` + Source string `json:"source"` +} + +// MutantResult contains the data about a specific mutant. +type MutantResult struct { + ID string `json:"id"` + MutatorName string `json:"mutatorName"` + Replacement string `json:"replacement"` + Location Location `json:"location"` + Status MTEStatus `json:"status"` + Description string `json:"description"` +} + +func (m *MutantResult) toMarvMutation() *mutations.Mutation { + return &mutations.Mutation{ + FrameworkMutantID: m.ID, + Description: m.Description, + Operation: m.MutatorName, + Start: m.Location.Start.toMarvRange(), + End: m.Location.End.toMarvRange(), + Status: m.Status.toMarvStatus(), + Replacement: m.Replacement, + } +} + +func (m *MutantResult) lineSpan() int { + return m.Location.End.Line - m.Location.Start.Line +} + +func (m *MutantResult) columnSpan() int { + return m.Location.End.Column - m.Location.Start.Column +} + +type MTEStatus string + +func (m MTEStatus) toMarvStatus() mutations.Status { + switch string(m) { + case "Survived": + return mutations.Survived + case "Killed": + return mutations.Killed + case "RuntimeError", "CompileError": + return mutations.Crashed + case "Timeout": + return mutations.Timeout + case "Pending": + return mutations.Pending + case "Ignored": + return mutations.Ignored + default: + return mutations.NoCoverage + } +} + +// Location describes a range within the source code. Start is inclusive, end is exclusive. +type Location struct { + Start Position `json:"start"` + End Position `json:"end"` +} + +// Position describes a single position within the source code. Both line and column start at one. +type Position struct { + Line int `json:"line"` + Column int `json:"column"` +} + +func (p *Position) toMarvRange() *mutations.Range { + return &mutations.Range{ + Line: p.Line - 1, + Char: p.Column - 1, + } +} + +type MTE struct { + result MutationTestResult + mutations mutations.Mutations + files map[string]string +} + +func NewMTE(file string) (*MTE, error) { + raw, err := os.ReadFile(file) + if err != nil { + return nil, err + } + mte := &MTE{} + if err := json.Unmarshal(raw, &mte.result); err != nil { + return nil, err + } + return mte, nil +} + +func (m *MTE) RawMutationsCount() int { + return m.result.CountMutations() +} + +func (m *MTE) Transform(bar *progressbar.ProgressBar) { + m.mutations = make(mutations.Mutations) + m.files = make(map[string]string) + + for file, fileResult := range m.result.Files { + if strings.HasPrefix(file, "/") { + file = file[1:] + } + m.files[file] = fileResult.Source + sortMutantsByRange(fileResult.Mutants) + for _, mutant := range fileResult.Mutants { + m.mutations.Append(file, mutant.toMarvMutation()) + bar.Add(1) + } + } + + m.result = MutationTestResult{} +} + +// sorting the mutants by range ensures that the best possible initial grouping of mutations into conflicts. this +// minimizes the amount of conflict merges that will have to be made when processing is finished. +func sortMutantsByRange(ms []MutantResult) { + sort.Slice(ms, func(i, j int) bool { + lsi := ms[i].lineSpan() + lsj := ms[j].lineSpan() + if lsi != lsj { + return lsi > lsj + } + csi := ms[i].columnSpan() + csj := ms[j].columnSpan() + if csi != csj { + return csi > csj + } + return ms[i].ID < ms[j].ID + }) +} + +func (m *MTE) Mutations() mutations.Mutations { + return m.mutations +} + +func (m *MTE) ReadLines(file string) []string { + return getLinesFromString(m.files[file]) +} + +func getLinesFromString(str string) []string { + lines := make([]string, 0) + for line := range strings.Lines(str) { + lines = append(lines, strings.ReplaceAll(line, "\n", "")) + } + return lines +} diff --git a/internal/mtelib/mtelib_test.go b/internal/mtelib/mtelib_test.go new file mode 100644 index 0000000..5ca80c2 --- /dev/null +++ b/internal/mtelib/mtelib_test.go @@ -0,0 +1,35 @@ +package mtelib + +import "testing" + +func TestSortingMutantsByRange(t *testing.T) { + mutants := []MutantResult{ + {ID: "1", Location: Location{Start: Position{Line: 72, Column: 13}, End: Position{Line: 102, Column: 46}}}, + {ID: "2", Location: Location{Start: Position{Line: 995, Column: 4}, End: Position{Line: 1000, Column: 28}}}, + {ID: "3", Location: Location{Start: Position{Line: 311, Column: 98}, End: Position{Line: 343, Column: 120}}}, + {ID: "4", Location: Location{Start: Position{Line: 9, Column: 0}, End: Position{Line: 21, Column: 7}}}, + {ID: "5", Location: Location{Start: Position{Line: 680, Column: 150}, End: Position{Line: 709, Column: 177}}}, + {ID: "6", Location: Location{Start: Position{Line: 257, Column: 33}, End: Position{Line: 287, Column: 66}}}, + {ID: "7", Location: Location{Start: Position{Line: 423, Column: 5}, End: Position{Line: 448, Column: 52}}}, + {ID: "8", Location: Location{Start: Position{Line: 56, Column: 199}, End: Position{Line: 91, Column: 200}}}, + {ID: "9", Location: Location{Start: Position{Line: 814, Column: 22}, End: Position{Line: 846, Column: 45}}}, + {ID: "10", Location: Location{Start: Position{Line: 137, Column: 0}, End: Position{Line: 166, Column: 12}}}, + {ID: "11", Location: Location{Start: Position{Line: 499, Column: 77}, End: Position{Line: 528, Column: 110}}}, + {ID: "12", Location: Location{Start: Position{Line: 921, Column: 3}, End: Position{Line: 946, Column: 9}}}, + {ID: "13", Location: Location{Start: Position{Line: 203, Column: 44}, End: Position{Line: 236, Column: 94}}}, + {ID: "14", Location: Location{Start: Position{Line: 35, Column: 12}, End: Position{Line: 64, Column: 60}}}, + {ID: "15", Location: Location{Start: Position{Line: 742, Column: 185}, End: Position{Line: 772, Column: 200}}}, + {ID: "16", Location: Location{Start: Position{Line: 608, Column: 0}, End: Position{Line: 638, Column: 25}}}, + {ID: "17", Location: Location{Start: Position{Line: 482, Column: 140}, End: Position{Line: 512, Column: 178}}}, + {ID: "18", Location: Location{Start: Position{Line: 120, Column: 66}, End: Position{Line: 149, Column: 99}}}, + {ID: "19", Location: Location{Start: Position{Line: 871, Column: 9}, End: Position{Line: 894, Column: 37}}}, + {ID: "20", Location: Location{Start: Position{Line: 34, Column: 2}, End: Position{Line: 65, Column: 53}}}, + } + expectedIDOrder := []string{"8", "13", "9", "3", "20", "17", "1", "6", "16", "15", "14", "11", "18", "5", "10", "7", "12", "19", "4", "2"} + sortMutantsByRange(mutants) + for i, mutant := range mutants { + if mutant.ID != expectedIDOrder[i] { + t.Errorf("expected id in position %d to be \"%s\" but got \"%s\"", i, expectedIDOrder[i], mutant.ID) + } + } +} diff --git a/internal/mutations/mutations.go b/internal/mutations/mutations.go index 679a094..b667a2d 100644 --- a/internal/mutations/mutations.go +++ b/internal/mutations/mutations.go @@ -17,9 +17,11 @@ const ( Crashed Status = "CRASHED" Timeout Status = "TIMEOUT" NoCoverage Status = "NO_COVERAGE" + Pending Status = "PENDING" + Ignored Status = "IGNORED" ) -var Statuses = []Status{Killed, Survived, Crashed, Timeout, NoCoverage} +var Statuses = []Status{Killed, Survived, Crashed, Timeout, NoCoverage, Ignored, Pending} var statusPaths = map[Status]string{ Killed: "", + // NOTE: Pending will just use the NoCoverage Image. + Ignored: "", } func (s Status) Text() string { @@ -63,6 +74,20 @@ type Range struct { Char int } +func (r Range) LessThan(rge *Range) bool { + if r.Line < rge.Line { + return true + } + if r.Line == rge.Line && r.Char < rge.Char { + return true + } + return false +} + +func (r Range) String() string { + return fmt.Sprintf("L%dC%d", r.Line, r.Char) +} + // Mutation represents a single mutation. type Mutation struct { ID uuid.UUID @@ -75,6 +100,19 @@ type Mutation struct { Replacement string } +func (m Mutation) GetDescription() string { + if m.Description == "" { + return m.Operation + } + return m.Description +} + +// IsBroken returns whether a mutation is broken or not. Marv treats any mutation where the End Range is lower than +// the Start Range. +func (m Mutation) IsBroken() bool { + return m.End.LessThan(m.Start) +} + // Conflict represents all mutations that would conflict with each other if they were displayed simultaneously. type Conflict struct { ID uuid.UUID @@ -91,18 +129,38 @@ func NewConflict(m *Mutation) *Conflict { } } -func (c *Conflict) Conflicts(m *Mutation) bool { - return m.Start.Line >= c.StartLine && m.Start.Line <= c.EndLine || - m.End.Line >= c.StartLine && m.End.Line <= c.EndLine +func (c *Conflict) ConflictsWithMutation(m *Mutation) bool { + return m.Start.Line <= c.EndLine && m.End.Line >= c.StartLine } -func (c *Conflict) Append(m *Mutation) { +func (c *Conflict) ConflictsWithConflict(cb *Conflict) bool { + return cb.StartLine <= c.EndLine && cb.EndLine >= c.StartLine +} + +func (c *Conflict) ResizeToInclude(m *Mutation) { + if m.Start.Line < c.StartLine { + c.StartLine = m.Start.Line + } if m.End.Line > c.EndLine { c.EndLine = m.End.Line } +} + +func (c *Conflict) Append(m *Mutation) { + c.ResizeToInclude(m) c.Mutations = append(c.Mutations, m) } +func (c *Conflict) Merge(cb *Conflict) { + if cb.StartLine < c.StartLine { + c.StartLine = cb.StartLine + } + if cb.EndLine > c.EndLine { + c.EndLine = cb.EndLine + } + c.Mutations = append(c.Mutations, cb.Mutations...) +} + // Conflicts is a slice of Conflict instances. type Conflicts []*Conflict @@ -133,20 +191,63 @@ func (m Mutations) Merge(b Mutations) { } func (m Mutations) Append(file string, mutation *Mutation) { - added := false for _, c := range m[file] { - if c.Conflicts(mutation) { + if c.ConflictsWithMutation(mutation) { c.Append(mutation) - added = true - break + return } } + m[file] = append(m[file], NewConflict(mutation)) +} - if !added { - m[file] = append(m[file], NewConflict(mutation)) +func (m Mutations) MergeConflicting() { + for file, conflicts := range m { + if len(conflicts) <= 1 { + continue + } + + merged := make(Conflicts, 0, len(conflicts)) + current := conflicts[0] + for _, conflict := range conflicts[1:] { + if current.ConflictsWithConflict(conflict) { + current.Merge(conflict) + continue + } + merged = append(merged, current) + current = conflict + } + + m[file] = append(merged, current) } } +func (m Mutations) ExtractBrokenMutations() []*Mutation { + broken := make([]*Mutation, 0) + for file, conflicts := range m { + remaining := make(Conflicts, 0, len(m[file])) + + for _, conflict := range conflicts { + mutations := make([]*Mutation, 0, len(conflict.Mutations)) + + for _, mutation := range conflict.Mutations { + if mutation.IsBroken() { + broken = append(broken, mutation) + continue + } + mutations = append(mutations, mutation) + } + + if len(mutations) > 0 { + conflict.Mutations = mutations + remaining = append(remaining, conflict) + } + } + + m[file] = remaining + } + return broken +} + // GenerateIDs generates UUIDs for all conflicts and mutations func (m Mutations) GenerateIDs() { for _, conflicts := range m { @@ -181,18 +282,34 @@ type Statistics struct { StatusCounts map[Status]float64 } -func (s Statistics) covered() float64 { - return s.Count - s.StatusCounts[NoCoverage] +func (s Statistics) Detected() float64 { + return s.StatusCounts[Killed] + s.StatusCounts[Timeout] } -func (s Statistics) Coverage() float64 { - return s.covered() / s.Count * 100 +func (s Statistics) Undetected() float64 { + return s.StatusCounts[Survived] + s.StatusCounts[NoCoverage] +} + +func (s Statistics) Covered() float64 { + return s.Detected() + s.StatusCounts[Survived] +} + +func (s Statistics) Valid() float64 { + return s.Detected() + s.Undetected() +} + +func (s Statistics) Invalid() float64 { + return s.StatusCounts[Crashed] } func (s Statistics) Score() float64 { - return s.StatusCounts[Killed] / s.Count * 100 + return s.Detected() / s.Valid() * 100 } func (s Statistics) ScoreOfCovered() float64 { - return s.StatusCounts[Killed] / s.covered() * 100 + return s.Detected() / s.Covered() * 100 +} + +func (s Statistics) Coverage() float64 { + return s.Covered() / s.Count * 100 } diff --git a/internal/mutations/mutations_test.go b/internal/mutations/mutations_test.go index 485e7e0..0ffd1b7 100644 --- a/internal/mutations/mutations_test.go +++ b/internal/mutations/mutations_test.go @@ -1,134 +1,409 @@ package mutations -import "testing" - -var m1 = &Mutation{ - FrameworkMutantID: "0", - Description: "test mutation 1", - Operation: "bla bla bla", - Start: &Range{ - Line: 44, - Char: 4, - }, - End: &Range{ - Line: 70, - Char: 92, - }, - Status: Killed, - Replacement: "line that was replaced ...", -} -var m2 = &Mutation{ - FrameworkMutantID: "1", - Description: "test mutation 2", - Operation: "bla bla bla", - Start: &Range{ - Line: 60, - Char: 40, - }, - End: &Range{ - Line: 1006, - Char: 30, - }, - Status: Killed, - Replacement: "line that was replaced ...", -} -var m3 = &Mutation{ - FrameworkMutantID: "2", - Description: "test mutation 3", - Operation: "bla bla bla", - Start: &Range{ - Line: 20, - Char: 0, - }, - End: &Range{ - Line: 43, - Char: 400, - }, - Status: Killed, - Replacement: "line that was replaced ...", -} -var m4 = &Mutation{ - FrameworkMutantID: "2", - Description: "test mutation 3", - Operation: "bla bla bla", - Start: &Range{ - Line: 20, - Char: 0, - }, - End: &Range{ - Line: 50, - Char: 400, - }, - Status: Killed, - Replacement: "line that was replaced ...", -} - -type conflictsTestCase struct { - name string - conflict *Conflict - mutation *Mutation - expConflict bool - expConflictEndLine int -} - -var conflictsTestCases = []conflictsTestCase{ - { - name: "no conflict", - conflict: NewConflict(m1), - mutation: m3, - expConflict: false, - }, - { - name: "conflict with start line contained between conflict start and end line", - conflict: NewConflict(m1), - mutation: m2, - expConflict: true, - expConflictEndLine: m2.End.Line, - }, - { - name: "conflict with start line before conflict start line, but end line between conflict start and end line", - conflict: NewConflict(m1), - mutation: m4, - expConflict: true, - expConflictEndLine: m1.End.Line, - }, -} - -func assertConflicts(t *testing.T, c conflictsTestCase) { - conf := c.conflict.Conflicts(c.mutation) - if conf != c.expConflict && c.expConflict { - t.Errorf("conflict between Conflict{%s} and Mutation{%s} was not detected", - c.conflict.Mutations[0].FrameworkMutantID, c.mutation.FrameworkMutantID) - } - if conf != c.expConflict && !c.expConflict { - t.Errorf("unexpected conflict detected between Conflict{%s} and Mutation{%s}", - c.conflict.Mutations[0].FrameworkMutantID, c.mutation.FrameworkMutantID) - } -} - -func TestConflict_Conflicts(t *testing.T) { - for _, c := range conflictsTestCases { - t.Run(c.name, func(t *testing.T) { - assertConflicts(t, c) +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestStatusToTextConversion(t *testing.T) { + tests := []struct { + Status Status + Expected string + }{ + {Killed, "killed"}, + {Survived, "survived"}, + {Crashed, "crashed"}, + {Timeout, "timeout"}, + {NoCoverage, "no_coverage"}, + {Status("OTHER_STATUS"), "other_status"}, + } + for _, test := range tests { + t.Run(fmt.Sprintf("%s.Text() == \"%s\"?", test.Status, test.Expected), func(t *testing.T) { + result := test.Status.Text() + if result != test.Expected { + t.Errorf("expected %s but got %s", test.Expected, result) + } }) } } -func assertAppend(t *testing.T, c conflictsTestCase) { - if !c.expConflict { - return +func TestStatusToIconWithTextConversion(t *testing.T) { + tests := []struct { + Status Status + ExpectedSubstr string + }{ + {Killed, "

killed

"}, + {Survived, "

survived

"}, + {Crashed, "

crashed

"}, + {Timeout, "

timeout

"}, + {NoCoverage, "

no_coverage

"}, + // looking for part of the no_coverage icon + {Status("OTHER_STATUS"), "M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zm0-336c-17.7 0-32 14.3"}, } - c.conflict.Append(c.mutation) - if c.conflict.EndLine != c.expConflictEndLine { - t.Errorf("conflict Conflict{%s} expected end line to be %d, got %d", - c.conflict.Mutations[0].FrameworkMutantID, c.expConflictEndLine, c.conflict.EndLine) + for _, test := range tests { + t.Run(fmt.Sprintf("%s.IconWithText() contains \"%s\"?", test.Status, test.ExpectedSubstr), func(t *testing.T) { + result := test.Status.IconWithText() + if !strings.Contains(result, test.ExpectedSubstr) { + t.Errorf("expected %s but got %s", test.ExpectedSubstr, result) + } + }) } } -func TestConflict_Append(t *testing.T) { - for _, c := range conflictsTestCases { - t.Run(c.name, func(t *testing.T) { - assertAppend(t, c) +func TestRangeLessThanComparisons(t *testing.T) { + // NOTE: test will always do RangeA.LessThan(RangeB). + tests := []struct { + RangeA Range + RangeB Range + ExpectRangeA bool // true if test is expecting RangeA to be less than RangeB + }{ + {Range{Line: 0, Char: 0}, Range{Line: 1, Char: 0}, true}, + {Range{Line: 100, Char: 0}, Range{Line: 1, Char: 0}, false}, + {Range{Line: 1, Char: 2}, Range{Line: 1, Char: 0}, false}, + {Range{Line: 1, Char: 2}, Range{Line: 1, Char: 15}, true}, + } + for _, test := range tests { + t.Run(fmt.Sprintf("%s.LessThan(%s) == %v?", test.RangeA, test.RangeB, test.ExpectRangeA), func(t *testing.T) { + result := test.RangeA.LessThan(&test.RangeB) + if result != test.ExpectRangeA { + t.Errorf("expected %v but got %v", test.ExpectRangeA, result) + } }) } } + +func TestMutationReturnsOperationWhenNoDescription(t *testing.T) { + tests := []struct { + Name string + Mutation Mutation + Expected string + }{ + {"Mutation returns description", Mutation{Description: "deleted `x`", Operation: "deletion_mutator"}, "deleted `x`"}, + {"Mutation returns operation in place of empty description", Mutation{Operation: "deletion_mutator"}, "deletion_mutator"}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + result := test.Mutation.GetDescription() + if result != test.Expected { + t.Errorf("expected %s but got %s", test.Expected, result) + } + }) + } +} + +func TestMutationBrokenCheck(t *testing.T) { + tests := []struct { + Name string + Mutation Mutation + ExpectingBroken bool + }{ + {"Mutation is not broken", Mutation{Start: &Range{0, 0}, End: &Range{1, 0}}, false}, + {"Mutation is broken as start range > end range", Mutation{Start: &Range{10, 0}, End: &Range{1, 0}}, true}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + result := test.Mutation.IsBroken() + if result != test.ExpectingBroken { + t.Errorf("expected %v but got %v", test.ExpectingBroken, result) + } + }) + } +} + +func TestConflictCreationCreatesCorrectRegion(t *testing.T) { + m := Mutation{Start: &Range{10, 35}, End: &Range{10, 45}} + c := NewConflict(&m) + if c.StartLine != m.Start.Line { + t.Errorf("expected start line to be %d but got %d", m.Start.Line, c.StartLine) + } + if c.EndLine != m.End.Line { + t.Errorf("expected end line to be %d but got %d", m.End.Line, c.EndLine) + } +} + +func TestConflictCorrectlyReportsMutationsOverlap(t *testing.T) { + c := &Conflict{StartLine: 10, EndLine: 25} + tests := []struct { + Name string + Mutation Mutation + ExpectingConflict bool + }{ + {"Mutation outside of conflict zone", Mutation{Start: &Range{0, 0}, End: &Range{1, 0}}, false}, + {"Mutation overlaps lower boundary of conflict zone", Mutation{Start: &Range{8, 0}, End: &Range{12, 0}}, true}, + {"Mutation overlaps upper boundary of conflict zone", Mutation{Start: &Range{22, 0}, End: &Range{28, 0}}, true}, + {"Mutation wrapped by boundaries of conflict zone", Mutation{Start: &Range{14, 0}, End: &Range{20, 0}}, true}, + {"Mutation surrounds boundaries of conflict zone from outside the zone", Mutation{Start: &Range{4, 0}, End: &Range{28, 0}}, true}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + result := c.ConflictsWithMutation(&test.Mutation) + if result != test.ExpectingConflict { + t.Errorf("expected mutation conflict == %v but got %v", test.ExpectingConflict, result) + } + }) + } +} + +func TestConflictBoundaryExpansionWhenAppendingNewMutation(t *testing.T) { + // tests conducted with new conflict created inside of runner loop. conflict always starts with StartLine: 10 + // and EndLine: 25 + tests := []struct { + Name string + Mutation Mutation + ExpectedConflictStartLine int + ExpectedConflictEndLine int + }{ + {"Mutation expands lower boundary of conflict zone", Mutation{Start: &Range{8, 0}, End: &Range{12, 0}}, 8, 25}, + {"Mutation expands upper boundary of conflict zone", Mutation{Start: &Range{22, 0}, End: &Range{28, 0}}, 10, 28}, + {"Mutation does not expand boundaries of conflict zone", Mutation{Start: &Range{14, 0}, End: &Range{20, 0}}, 10, 25}, + {"Mutation expands both boundaries of conflict zone", Mutation{Start: &Range{4, 0}, End: &Range{28, 0}}, 4, 28}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + c := &Conflict{StartLine: 10, EndLine: 25} + c.Append(&test.Mutation) + if c.StartLine != test.ExpectedConflictStartLine { + t.Errorf("expected start line to be %d but got %d", test.ExpectedConflictStartLine, c.StartLine) + } + if c.EndLine != test.ExpectedConflictEndLine { + t.Errorf("expected end line to be %d but got %d", test.ExpectedConflictEndLine, c.EndLine) + } + }) + } +} + +func TestSortCorrectlyOrdersConflictsFromFirstToLastBasedOnStartLine(t *testing.T) { + c := Conflicts{ + &Conflict{StartLine: 1201}, + &Conflict{StartLine: 102}, + &Conflict{StartLine: 400}, + &Conflict{StartLine: 0}, + } + c.Sort() + if c[0].StartLine != 0 { + t.Errorf("expected first conflict to start from line 0 but got %d", c[0].StartLine) + } + if c[1].StartLine != 102 { + t.Errorf("expected second conflict to start from line 102 but got %d", c[1].StartLine) + } + if c[2].StartLine != 400 { + t.Errorf("expected third conflict to start from line 400 but got %d", c[2].StartLine) + } + if c[3].StartLine != 1201 { + t.Errorf("expected last conflict to start from line 1201 but got %d", c[3].StartLine) + } +} + +func TestConflictsGetMutant(t *testing.T) { + m1ID := uuid.New() + m2ID := uuid.New() + + m1 := Mutation{ID: m1ID} + m2 := Mutation{ID: m2ID} + + c := Conflicts{ + &Conflict{StartLine: 45, Mutations: []*Mutation{&m1}}, + &Conflict{StartLine: 65, Mutations: []*Mutation{&m2}}, + } + + tests := []struct { + ID uuid.UUID + Mutation *Mutation + ExpectedStartLine int + }{ + {m1ID, &m1, 45}, + {m2ID, &m2, 65}, + {uuid.Nil, nil, 0}, + } + for _, test := range tests { + t.Run(fmt.Sprintf("correctly retrieves mutant %s", test.ID), func(t *testing.T) { + conflict, mutant := c.GetMutant(test.ID) + if test.ID == uuid.Nil && conflict == nil && mutant == nil { + return // this passes the test as if id is nil then it is not looking for anything + } + if conflict.StartLine != test.ExpectedStartLine { + t.Fatalf("expected conflict start line to be %d but got %d", test.ExpectedStartLine, conflict.StartLine) + } + if mutant != test.Mutation { + t.Fatal("got an incorrect mutation reference") + } + }) + } +} + +func TestMergingMutationMaps(t *testing.T) { + m1 := Mutations{"file/path/1.lang": Conflicts{}} + m2 := Mutations{"file/path/2.lang": Conflicts{}} + m1.Merge(m2) + if m1["file/path/1.lang"] == nil { + t.Fatal("m1 lost contents of file/path/1.lang during merge operation") + } + if m1["file/path/2.lang"] == nil { + t.Fatal("m1 did not gain contents of file/path/2.lang during merge operation") + } +} + +func TestMutationsAppendAndMergeInWorstCaseScenario(t *testing.T) { + file := "FILE_NAME" + mut1 := &Mutation{Start: &Range{10, 0}, End: &Range{10, 20}} + mut2 := &Mutation{Start: &Range{11, 0}, End: &Range{11, 20}} + mut3 := &Mutation{Start: &Range{10, 0}, End: &Range{11, 20}} + m := make(Mutations) + m.Append(file, mut1) + m.Append(file, mut2) + m.Append(file, mut3) + m.MergeConflicting() + if len(m[file]) != 1 { + t.Errorf("was expecting 1 conflict but got %d", len(m[file])) + } +} + +func TestExtractingBrokenMutantsFromMutationsMap(t *testing.T) { + validMut1 := &Mutation{Start: &Range{1, 0}, End: &Range{2, 0}} + validMut2 := &Mutation{Start: &Range{1, 0}, End: &Range{2, 0}} + validMut3 := &Mutation{Start: &Range{1, 0}, End: &Range{2, 0}} + validMut4 := &Mutation{Start: &Range{1, 0}, End: &Range{2, 0}} + brokenMut1 := &Mutation{Start: &Range{100, 0}, End: &Range{0, 0}} + brokenMut2 := &Mutation{Start: &Range{1, 400}, End: &Range{1, 0}} + con1 := &Conflict{Mutations: []*Mutation{brokenMut1, validMut1}} + con2 := &Conflict{Mutations: []*Mutation{validMut3, validMut4, brokenMut2}} + m := Mutations{ + "file/path/1.lang": Conflicts{con1, &Conflict{Mutations: []*Mutation{validMut2}}}, + "file/path/2.lang": Conflicts{con2}, + } + broken := m.ExtractBrokenMutations() + if len(broken) != 2 { + t.Errorf("expected 2 broken mutations but got %d", len(broken)) + } + if !slices.Contains(broken, brokenMut1) { + t.Errorf("slice of broken mutations does not contain brokenMut1") + } + if slices.Contains(con1.Mutations, brokenMut1) { + t.Errorf("conflict1 of correct mutations contains brokenMut1") + } + if !slices.Contains(broken, brokenMut2) { + t.Errorf("slice of broken mutations does not contain brokenMut2") + } + if slices.Contains(con2.Mutations, brokenMut2) { + t.Errorf("conflict2 of correct mutations contains brokenMut2") + } +} + +func TestEnsureGenerateIDsAssignsAnIDToAllMutations(t *testing.T) { + m := Mutations{ + "file1.lang": Conflicts{ + &Conflict{Mutations: []*Mutation{{Status: Killed}}}, + &Conflict{Mutations: []*Mutation{{Status: Killed}}}, + }, + "file2.lang": Conflicts{ + &Conflict{Mutations: []*Mutation{{Status: Killed}}}, + &Conflict{Mutations: []*Mutation{{Status: Killed}}}, + }, + "file3.lang": Conflicts{ + &Conflict{Mutations: []*Mutation{{Status: Killed}}}, + &Conflict{Mutations: []*Mutation{{Status: Killed}}}, + }, + "file4.lang": Conflicts{ + &Conflict{Mutations: []*Mutation{{Status: Killed}}}, + &Conflict{Mutations: []*Mutation{{Status: Killed}}}, + }, + } + m.GenerateIDs() + for _, conflicts := range m { + for _, conflict := range conflicts { + for _, mutation := range conflict.Mutations { + if mutation.ID == uuid.Nil { + t.Errorf("mutation was not assigned an id") + } + } + } + } +} + +func TestGetStatisticsFromMutationsMap(t *testing.T) { + m := Mutations{ + // KILLED: 4, SURVIVED: 3, TIMEOUT: 1 + "root/file1.lang": Conflicts{ + &Conflict{Mutations: []*Mutation{ + {Status: Killed}, {Status: Killed}, {Status: Killed}, {Status: Survived}, + {Status: Survived}, {Status: Killed}, {Status: Timeout}, {Status: Survived}, + }}, + }, + // KILLED: 8, SURVIVED: 6, TIMEOUT: 2 + "root/path/file2.lang": Conflicts{ + &Conflict{Mutations: []*Mutation{ + {Status: Killed}, {Status: Killed}, {Status: Killed}, {Status: Survived}, + {Status: Survived}, {Status: Killed}, {Status: Timeout}, {Status: Survived}, + {Status: Killed}, {Status: Killed}, {Status: Killed}, {Status: Survived}, + {Status: Survived}, {Status: Killed}, {Status: Timeout}, {Status: Survived}, + }}, + }, + // KILLED: 2, CRASHED: 1 + "root/path/long/file.lang": Conflicts{ + &Conflict{Mutations: []*Mutation{ + {Status: Killed}, {Status: Killed}, {Status: Crashed}, + }}, + }, + } + rootStats := m.StatisticsFrom("root/") + if rootStats.StatusCounts[Killed] != 14 { + t.Errorf("expected 14 killed mutations on root/ but got %f", rootStats.StatusCounts[Killed]) + } + pathStats := m.StatisticsFrom("root/path/") + if pathStats.StatusCounts[Killed] != 10 { + t.Errorf("expected 10 killed mutations on root/path/ but got %f", pathStats.StatusCounts[Killed]) + } + fileStats := m.StatisticsFrom("root/path/long/file.lang") + if fileStats.StatusCounts[Crashed] != 1 { + t.Errorf("expected 1 crashed mutation on root/path/long/file.lang but got %f", fileStats.StatusCounts[Crashed]) + } +} + +func TestStatisticsCalculations(t *testing.T) { + var ( + killedCount float64 = 2102 + survivedCount float64 = 3042 + crashedCount float64 = 3 + timeoutCount float64 = 60 + noCoverageCount float64 = 1344 + count = killedCount + survivedCount + crashedCount + timeoutCount + noCoverageCount + ) + s := Statistics{ + Count: count, + StatusCounts: map[Status]float64{ + Killed: killedCount, + Survived: survivedCount, + Crashed: crashedCount, + Timeout: timeoutCount, + NoCoverage: noCoverageCount, + }, + } + if s.Detected() != killedCount+timeoutCount { + t.Errorf("incorrect detected calculation") + } + if s.Undetected() != survivedCount+noCoverageCount { + t.Errorf("incorrect undetected calculation") + } + if s.Covered() != killedCount+timeoutCount+survivedCount { + t.Errorf("incorrect covered calculation") + } + if s.Valid() != killedCount+timeoutCount+survivedCount+noCoverageCount { + t.Errorf("incorrect valid calculation") + } + if s.Invalid() != crashedCount { + t.Errorf("incorrect invalid calculation") + } + if s.Score() != (killedCount+timeoutCount)/(killedCount+timeoutCount+survivedCount+noCoverageCount)*100 { + t.Errorf("incorrect score calculation") + } + if s.ScoreOfCovered() != (killedCount+timeoutCount)/(killedCount+timeoutCount+survivedCount)*100 { + t.Errorf("incorrect score of covered calculation") + } + if s.Coverage() != (killedCount+timeoutCount+survivedCount)/count*100 { + t.Errorf("incorrect coverage calculation") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 5f0b7b7..66a6566 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -38,8 +38,8 @@ func NewServer(port int, frameworks []fwlib.Framework, db *review.Repository) *S "styles/generic.css", }, Scripts: []string{ - "scripts/tree.js", "scripts/status-filtering.js", + "scripts/tree.js", "scripts/review.js", }, }, frameworks, db), diff --git a/pkg/theming/theme.go b/pkg/theming/theme.go deleted file mode 100644 index 2445b95..0000000 --- a/pkg/theming/theme.go +++ /dev/null @@ -1,91 +0,0 @@ -package theming - -import ( - "fmt" - "strings" -) - -type CSS interface { - ToCSS() string -} - -type TextStyle struct { - Caret, Background, Color, FontStyle, TextDecoration, SelectionBackground, SelectionColor, SelectionBorder string -} - -func (t TextStyle) ToCSS() string { - css := strings.Builder{} - - appendStyle := func(property, value string) { - if value != "" { - css.WriteString(property) - css.WriteString(":") - css.WriteString(value) - css.WriteString(";") - } - } - - appendStyle("caret-color", t.Caret) - appendStyle("background", t.Background) - appendStyle("color", t.Color) - appendStyle("font-style", t.FontStyle) - appendStyle("text-decoration", t.TextDecoration) - - if t.SelectionBackground != "" || t.SelectionColor != "" || t.SelectionBorder != "" { - css.WriteString("&::selection{") - appendStyle("background", t.SelectionBackground) - appendStyle("color", t.SelectionColor) - appendStyle("border", t.SelectionBorder) - css.WriteString("}") - } - - return css.String() -} - -type Code struct { - Variable, Function, Keyword, String, Comment, Number, Operator, Type, Class, Interface, Constant, Property TextStyle -} - -func (c Code) ToCSS() string { - css := strings.Builder{} - - appendStyle := func(class, style string) { - css.WriteString(".") - css.WriteString(class) - css.WriteString("{") - css.WriteString(style) - css.WriteString("}") - } - - appendStyle("variable", c.Variable.ToCSS()) - appendStyle("function", c.Function.ToCSS()) - appendStyle("keyword", c.Keyword.ToCSS()) - appendStyle("string", c.String.ToCSS()) - appendStyle("comment", c.Comment.ToCSS()) - appendStyle("number", c.Number.ToCSS()) - appendStyle("operator", c.Operator.ToCSS()) - appendStyle("type", c.Type.ToCSS()) - appendStyle("class", c.Class.ToCSS()) - appendStyle("interface", c.Interface.ToCSS()) - appendStyle("constant", c.Constant.ToCSS()) - appendStyle("property", c.Property.ToCSS()) - - return css.String() -} - -type InterfaceColors struct { - // TODO: add when css exists, needs to overwrite :root variables with theme -} - -func (i InterfaceColors) ToCSS() string { - return ":root{}" -} - -type Theme struct { - Colors InterfaceColors - Code Code -} - -func (t Theme) ToCSS() string { - return fmt.Sprintf("%s%s", t.Colors.ToCSS(), t.Code.ToCSS()) -} diff --git a/pkg/theming/theme_test.go b/pkg/theming/theme_test.go deleted file mode 100644 index c8a7429..0000000 --- a/pkg/theming/theme_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package theming - -import "testing" - -type CSSTestCase struct { - Name string - Value CSS - Expected string -} - -func RunTestCases(t *testing.T, tests []CSSTestCase) { - for _, test := range tests { - t.Run(test.Name, func(t *testing.T) { - if test.Value.ToCSS() != test.Expected { - t.Errorf("expected %s\ngot %s", test.Value.ToCSS(), test.Expected) - } - }) - } -} - -func TestTextStyle_ToCSS(t *testing.T) { - RunTestCases(t, []CSSTestCase{ - { - "Correctly generates minimal css consisting of color and selection color", - TextStyle{Color: "#fff", SelectionColor: "#000"}, - "color:#fff;&::selection{color:#000;}", - }, - { - "Correctly generates minimal css consisting of selection color, background and border", - TextStyle{SelectionColor: "#000", SelectionBackground: "#fff", SelectionBorder: "none"}, - "&::selection{background:#fff;color:#000;border:none;}", - }, - }) -} - -func TestCode_ToCSS(t *testing.T) { - RunTestCases(t, []CSSTestCase{ - { - "Correctly generates all classes with at least one piece of data", - Code{ - Variable: TextStyle{Color: "#fff", SelectionBackground: "#000"}, - Function: TextStyle{Background: "#fff"}, - Keyword: TextStyle{FontStyle: "italic"}, - String: TextStyle{Color: "#aaafff"}, - Comment: TextStyle{Background: "black", TextDecoration: "underline"}, - Number: TextStyle{Color: "#fff"}, - Operator: TextStyle{Color: "blue"}, - Type: TextStyle{Color: "#fff"}, - Class: TextStyle{Color: "black"}, - Interface: TextStyle{Color: "#fff"}, - Constant: TextStyle{Color: "#fff"}, - Property: TextStyle{Color: "#fff"}, - }, - ".variable{color:#fff;&::selection{background:#000;}}.function{background:#fff;}" + - ".keyword{font-style:italic;}.string{color:#aaafff;}" + - ".comment{background:black;text-decoration:underline;}.number{color:#fff;}.operator{color:blue;}" + - ".type{color:#fff;}.class{color:black;}.interface{color:#fff;}.constant{color:#fff;}.property{color:#fff;}", - }, - }) -} diff --git a/web/static/languages/cpp_logo.svg b/web/static/languages/cpp_logo.svg new file mode 100644 index 0000000..6cae222 --- /dev/null +++ b/web/static/languages/cpp_logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/static/languages/dotnet_logo.svg b/web/static/languages/dotnet_logo.svg new file mode 100644 index 0000000..d204a09 --- /dev/null +++ b/web/static/languages/dotnet_logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/static/languages/go_logo_blue.svg b/web/static/languages/go_logo_blue.svg new file mode 100644 index 0000000..da6ea83 --- /dev/null +++ b/web/static/languages/go_logo_blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/static/languages/golang-brands-solid.svg b/web/static/languages/golang-brands-solid.svg deleted file mode 100644 index f077773..0000000 --- a/web/static/languages/golang-brands-solid.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/static/languages/java-brands-solid.svg b/web/static/languages/java-brands-solid.svg deleted file mode 100644 index b0cf336..0000000 --- a/web/static/languages/java-brands-solid.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/static/languages/java_duke_icon.svg b/web/static/languages/java_duke_icon.svg new file mode 100644 index 0000000..9ad31ef --- /dev/null +++ b/web/static/languages/java_duke_icon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/web/static/languages/js_logo.png b/web/static/languages/js_logo.png new file mode 100644 index 0000000..4675be4 Binary files /dev/null and b/web/static/languages/js_logo.png differ diff --git a/web/static/languages/marv_scala_icon.png b/web/static/languages/marv_scala_icon.png new file mode 100644 index 0000000..4cd9c1f Binary files /dev/null and b/web/static/languages/marv_scala_icon.png differ diff --git a/web/static/languages/php_logo.svg b/web/static/languages/php_logo.svg new file mode 100644 index 0000000..37a5e6f --- /dev/null +++ b/web/static/languages/php_logo.svg @@ -0,0 +1,96 @@ + + + Official PHP Logo + + + + image/svg+xml + + Official PHP Logo + + + Colin Viebrock + + + + + + + + + + + + Copyright Colin Viebrock 1997 - All rights reserved. + + + 1997 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/static/languages/rust-brands-solid.svg b/web/static/languages/rust-brands-solid.svg deleted file mode 100644 index 8a475ba..0000000 --- a/web/static/languages/rust-brands-solid.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/static/languages/rust_logo.svg b/web/static/languages/rust_logo.svg new file mode 100644 index 0000000..1838439 --- /dev/null +++ b/web/static/languages/rust_logo.svg @@ -0,0 +1,12 @@ + + diff --git a/web/static/languages/ts_logo_128.svg b/web/static/languages/ts_logo_128.svg new file mode 100644 index 0000000..b65a93a --- /dev/null +++ b/web/static/languages/ts_logo_128.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/styles/code.css b/web/styles/code.css index 7a7159d..7990796 100644 --- a/web/styles/code.css +++ b/web/styles/code.css @@ -227,15 +227,17 @@ } &.crashed { - fill: var(--status-crashed-fg) + fill: var(--status-crashed-fg); } &.timeout { - fill: var(--status-timeout-fg) + fill: var(--status-timeout-fg); } - &.no_coverage { - fill: var(--status-no-coverage-fg) + &.no_coverage, + &.ignored, + &.pending { + fill: var(--status-no-coverage-fg); } } @@ -290,7 +292,9 @@ } } - &.no_coverage { + &.no_coverage, + &.ignored, + &.pending { background: var(--status-no-coverage-bg); &::before { diff --git a/web/styles/filters.css b/web/styles/filters.css index 70a225b..99d84a5 100644 --- a/web/styles/filters.css +++ b/web/styles/filters.css @@ -45,7 +45,7 @@ .filters-wrapper { display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 2px; } } @@ -76,6 +76,7 @@ align-items: center; gap: 8px; user-select: none; + width: 49%; &:hover { background: var(--main-hover-bg); diff --git a/web/styles/main.css b/web/styles/main.css index df5f96c..e50daf4 100644 --- a/web/styles/main.css +++ b/web/styles/main.css @@ -49,7 +49,7 @@ --status-timeout-fg: #ffff00; --status-timeout-bg: #606020; --status-no-coverage-fg: #808080; - --status-no-coverage-bg: #404040; + --status-no-coverage-bg: #474747; --mutation-border-fg: #565656; --mutation-border: solid 1px var(--mutation-border-fg);