├── .codegen └── instructions │ └── ruby.md ├── .deepsource.toml ├── .env.example ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── .simplecov ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── config └── default │ └── .codegen │ └── instructions │ ├── react-vitest.md │ └── rspec-rails.md ├── docs └── configuration.md ├── dotcodegen.gemspec ├── exe └── codegen ├── lib ├── dotcodegen.rb └── dotcodegen │ ├── cli.rb │ ├── format_output.rb │ ├── init.rb │ ├── lint_code.rb │ ├── test_code_generator.rb │ ├── test_file_generator.rb │ └── version.rb ├── sig └── dotcodegen.rbs └── spec ├── codegen_spec.rb ├── dotcodegen ├── format_output_spec.rb ├── lint_code_spec.rb ├── test_code_generator_spec.rb └── test_file_generator_spec.rb ├── dotcodegen_spec.rb ├── fixtures ├── .codegen │ ├── rails.md │ └── react.md ├── feature.rb ├── feature.tsx └── labels_controller.rb └── spec_helper.rb /.codegen/instructions/ruby.md: -------------------------------------------------------------------------------- 1 | --- 2 | regex: 'lib/.*\.rb' 3 | root_path: 'lib' 4 | test_root_path: 'spec' 5 | test_file_suffix: '_spec.rb' 6 | --- 7 | 8 | When writing a test, you should follow these steps: 9 | 10 | 1. Avoid typos. 11 | 2. Avoid things that could be infinite loops. 12 | 3. This codebase is a Ruby gem, try to follow the conventions of the Ruby community. 13 | 4. Avoid things that could be security vulnerabilities. 14 | 5. Keep the codebase clean and easy to understand. 15 | 6. Use Rspec for tests, don't use any other testing framework. 16 | 7. Don't include ANY dependencies that are not already in the files you are provided. 17 | 8. Don't start your tests with ``` or other strings, as your reply will be run as a Ruby file. 18 | 19 | Here's an example of a good test you should reply with: 20 | 21 | ```ruby 22 | # frozen_string_literal: true 23 | 24 | require 'dotcodegen/test_file_generator' 25 | 26 | RSpec.describe Dotcodegen::TestFileGenerator do 27 | let(:file_path) { 'client/app/components/feature.tsx' } 28 | let(:api_matcher) do 29 | { 30 | 'regex' => 'api/.*\.rb', 31 | 'root_path' => 'api/app/', 32 | 'test_root_path' => 'api/spec/', 33 | 'test_file_suffix' => '_spec.rb' 34 | } 35 | end 36 | let(:client_matcher) do 37 | { 38 | 'regex' => 'client/app/.*\.tsx', 39 | 'test_file_suffix' => '.test.tsx' 40 | } 41 | end 42 | let(:matchers) { [api_matcher, client_matcher] } 43 | let(:openai_key) { 'test_openai_key' } 44 | let(:codegen_instance) { instance_double(Dotcodegen::TestFileGenerator) } 45 | 46 | subject { described_class.new(file_path:, matchers:, openai_key:) } 47 | 48 | describe '#run' do 49 | after(:each) { FileUtils.remove_dir('client/', force: true) } 50 | let(:file_path) { 'spec/fixtures/feature.tsx' } 51 | let(:client_matcher) do 52 | { 53 | 'regex' => 'spec/fixtures/.*\.tsx', 54 | 'test_file_suffix' => '.test.tsx', 55 | 'root_path' => 'spec/fixtures/', 56 | 'test_root_path' => 'tmp/codegen_spec/', 57 | 'instructions' => 'instructions/react.md' 58 | } 59 | end 60 | 61 | context 'when test file does not exist' do 62 | it 'creates a test file and writes generated code once' do 63 | allow(File).to receive(:exist?).with('tmp/codegen_spec/feature.test.tsx').and_return(false) 64 | expect(FileUtils).to receive(:mkdir_p).with('tmp/codegen_spec') 65 | allow(Dotcodegen::TestCodeGenerator).to receive_message_chain(:new, :generate_test_code).and_return('Mocked generated code') 66 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.tsx', '').once 67 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.tsx', 'Mocked generated code').once 68 | subject.run 69 | end 70 | end 71 | 72 | context 'when test file already exists' do 73 | it 'does not create a test file but writes generated code' do 74 | allow(File).to receive(:exist?).with('tmp/codegen_spec/feature.test.tsx').and_return(true) 75 | expect(FileUtils).not_to receive(:mkdir_p) 76 | allow(Dotcodegen::TestCodeGenerator).to receive_message_chain(:new, :generate_test_code).and_return('Mocked generated code') 77 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.tsx', 'Mocked generated code').once 78 | subject.run 79 | end 80 | end 81 | end 82 | 83 | describe '#matcher' do 84 | it 'returns the matching regex for the frontend' do 85 | expect(subject.matcher).to eq(client_matcher) 86 | end 87 | 88 | context 'when file path is a ruby file' do 89 | let(:file_path) { 'api/app/models/app.rb' } 90 | it 'returns the matching regex for the backend' do 91 | expect(subject.matcher).to eq(api_matcher) 92 | end 93 | end 94 | 95 | context 'when there are no matches' do 96 | let(:file_path) { 'terraform/models/app.rb' } 97 | it 'returns nil' do 98 | expect(subject.matcher).to be_nil 99 | end 100 | end 101 | 102 | context 'when file path does not match any regex' do 103 | let(:file_path) { 'api/models/app.go' } 104 | it 'returns nil' do 105 | expect(subject.matcher).to be_nil 106 | end 107 | end 108 | end 109 | 110 | describe '#test_file_path' do 111 | it 'returns the test file path for the frontend' do 112 | expect(subject.test_file_path).to eq('client/app/components/feature.test.tsx') 113 | end 114 | 115 | context 'when file path is a ruby file' do 116 | let(:file_path) { 'api/app/models/app.rb' } 117 | it 'returns the test file path for the backend' do 118 | expect(subject.test_file_path).to eq('api/spec/models/app_spec.rb') 119 | end 120 | end 121 | end 122 | end 123 | ``` 124 | 125 | Here's the skeleton of a test you can start from: 126 | 127 | ```ruby 128 | # frozen_string_literal: true 129 | 130 | require 'dotcodegen/__file_path__' 131 | 132 | RSpec.describe Dotcodegen::__CLASS_NAME__ do 133 | let(:params) do 134 | { 135 | # Add params here 136 | } 137 | end 138 | subject { described_class.new(params) } 139 | 140 | 141 | it 'runs' do 142 | # Add assertions here 143 | end 144 | end 145 | ``` -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "shell" 5 | 6 | [[analyzers]] 7 | name = "ruby" -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_KEY=your-api-key 2 | OPENAI_ORG_ID=your-openai-org-id -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Tests ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.3.0' 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Set up Code Climate test reporter 27 | uses: paambaati/codeclimate-action@v3.0.0 28 | env: 29 | CC_TEST_REPORTER_ID: 56d2e4890ee24451b85050b0364f1d1aa92462cdd296dfb4dd38928427e3ce93 30 | with: 31 | coverageCommand: bundle exec rake spec 32 | - name: Install dependencies 33 | run: bundle install 34 | - name: Run tests 35 | run: bundle exec rake spec 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | coverage.data 4 | 5 | /.bundle/ 6 | /.yardoc 7 | /_yardoc/ 8 | /coverage/ 9 | /doc/ 10 | /pkg/ 11 | /spec/reports/ 12 | /tmp/ 13 | 14 | # rspec failure tracking 15 | .rspec_status 16 | 17 | .DS_Store 18 | .env 19 | *.gem 20 | .vscode/* -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.0 2 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SimpleCov.start do 4 | add_filter '/spec/' 5 | add_filter '/vendor/bundle/' 6 | end 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.1.5] - 2024-03-11 4 | 5 | - Add support for specifying the OpenAI organization in the codegen command via the --openai_org_id flag. 6 | 7 | ## [0.1.4] - 2024-03-11 8 | 9 | - Strip the leading and trailing ``` from the output of the codegen command. 10 | - Remove the organization from the OpenAI initialization code. 11 | 12 | # [0.1.3] - 2024-03-10 13 | 14 | - Move the execution to a /exe/codegen file. exe/run was removed. 15 | 16 | ## [0.1.1] - 2024-03-10 17 | 18 | - Fix bug where the gemspec was excluding the `lib` directory from the gem. ([#1](https://github.com/ferrucc-io/dotcodegen/pull/1)) 19 | 20 | ## [0.1.0] - 2024-03-10 21 | 22 | - Initial release 23 | - Support for Ruby 3.3.0 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in dotcodegen.gemspec 6 | gemspec 7 | 8 | gem 'rake', '~> 13.0' 9 | gem 'rspec', '~> 3.0' 10 | gem 'simplecov', '~> 0.22' 11 | gem 'pry' 12 | gem "standard" 13 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | dotcodegen (0.1.5) 5 | dotenv 6 | front_matter_parser 7 | ruby-openai 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | ast (2.4.2) 13 | coderay (1.1.3) 14 | diff-lcs (1.5.1) 15 | docile (1.4.1) 16 | dotenv (3.1.2) 17 | event_stream_parser (1.0.0) 18 | faraday (2.11.0) 19 | faraday-net_http (>= 2.0, < 3.4) 20 | logger 21 | faraday-multipart (1.0.4) 22 | multipart-post (~> 2) 23 | faraday-net_http (3.3.0) 24 | net-http 25 | front_matter_parser (1.0.1) 26 | json (2.7.2) 27 | language_server-protocol (3.17.0.3) 28 | lint_roller (1.1.0) 29 | logger (1.6.1) 30 | method_source (1.1.0) 31 | multipart-post (2.4.1) 32 | net-http (0.4.1) 33 | uri 34 | parallel (1.26.3) 35 | parser (3.3.5.0) 36 | ast (~> 2.4.1) 37 | racc 38 | pry (0.14.2) 39 | coderay (~> 1.1) 40 | method_source (~> 1.0) 41 | racc (1.8.1) 42 | rainbow (3.1.1) 43 | rake (13.2.1) 44 | regexp_parser (2.9.2) 45 | rexml (3.3.7) 46 | rspec (3.13.0) 47 | rspec-core (~> 3.13.0) 48 | rspec-expectations (~> 3.13.0) 49 | rspec-mocks (~> 3.13.0) 50 | rspec-core (3.13.1) 51 | rspec-support (~> 3.13.0) 52 | rspec-expectations (3.13.2) 53 | diff-lcs (>= 1.2.0, < 2.0) 54 | rspec-support (~> 3.13.0) 55 | rspec-mocks (3.13.1) 56 | diff-lcs (>= 1.2.0, < 2.0) 57 | rspec-support (~> 3.13.0) 58 | rspec-support (3.13.1) 59 | rubocop (1.65.1) 60 | json (~> 2.3) 61 | language_server-protocol (>= 3.17.0) 62 | parallel (~> 1.10) 63 | parser (>= 3.3.0.2) 64 | rainbow (>= 2.2.2, < 4.0) 65 | regexp_parser (>= 2.4, < 3.0) 66 | rexml (>= 3.2.5, < 4.0) 67 | rubocop-ast (>= 1.31.1, < 2.0) 68 | ruby-progressbar (~> 1.7) 69 | unicode-display_width (>= 2.4.0, < 3.0) 70 | rubocop-ast (1.32.2) 71 | parser (>= 3.3.1.0) 72 | rubocop-performance (1.21.1) 73 | rubocop (>= 1.48.1, < 2.0) 74 | rubocop-ast (>= 1.31.1, < 2.0) 75 | ruby-openai (7.1.0) 76 | event_stream_parser (>= 0.3.0, < 2.0.0) 77 | faraday (>= 1) 78 | faraday-multipart (>= 1) 79 | ruby-progressbar (1.13.0) 80 | simplecov (0.22.0) 81 | docile (~> 1.1) 82 | simplecov-html (~> 0.11) 83 | simplecov_json_formatter (~> 0.1) 84 | simplecov-html (0.12.3) 85 | simplecov_json_formatter (0.1.4) 86 | standard (1.40.0) 87 | language_server-protocol (~> 3.17.0.2) 88 | lint_roller (~> 1.0) 89 | rubocop (~> 1.65.0) 90 | standard-custom (~> 1.0.0) 91 | standard-performance (~> 1.4) 92 | standard-custom (1.0.2) 93 | lint_roller (~> 1.0) 94 | rubocop (~> 1.50) 95 | standard-performance (1.4.0) 96 | lint_roller (~> 1.1) 97 | rubocop-performance (~> 1.21.0) 98 | unicode-display_width (2.5.0) 99 | uri (0.13.1) 100 | 101 | PLATFORMS 102 | arm64-darwin-23 103 | ruby 104 | 105 | DEPENDENCIES 106 | dotcodegen! 107 | pry 108 | rake (~> 13.0) 109 | rspec (~> 3.0) 110 | simplecov (~> 0.22) 111 | standard 112 | 113 | BUNDLED WITH 114 | 2.5.6 115 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Ferruccio Balestreri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # .codegen - Automatic test generation for your projects 2 | [![Gem Version](https://badge.fury.io/rb/dotcodegen.svg)](https://badge.fury.io/rb/dotcodegen) [![Test Coverage](https://api.codeclimate.com/v1/badges/8a9e8ffdf8f3c5322196/test_coverage)](https://codeclimate.com/github/ferrucc-io/dotcodegen/test_coverage) 3 | 4 | 5 | **DEPRECATION NOTICE - This project is no longer maintained. See [.cursorrules](https://docs.cursor.com/context/rules-for-ai) as a more natively integrated way of passing file path specific context to LLMs** 6 | 7 | Never write a test from scratch again. Automatically generate tests for any file you open in your codebase. 8 | 9 | Keep your team up to date with the latest best practices and conventions you adopt. Customize the templates to fit your team's needs. 10 | 11 | We've built this tool internally to speed up writing tests for our monolith at [June](https://june.so). The main idea is that across your codebase, you can have a set of instructions that are used to generate tests. These templates are configurable so no matter what framework or language your current file is in, you can generate tests that fit your team's needs. 12 | 13 | Now we're open-sourcing it so you can use it too. 14 | 15 | https://github.com/ferrucc-io/dotcodegen/assets/8315559/aca74a87-5123-4305-88ff-cc3be3f34a9f 16 | 17 | 18 | ## Get started 19 | 20 | 21 | 1. Install our CLI via Homebrew: 22 | 23 | ```bash 24 | brew tap ferrucc-io/dotcodegen-tap 25 | brew install dotcodegen 26 | ``` 27 | 28 | Or via RubyGems, this requires Ruby 3.3.0: 29 | 30 | ```bash 31 | gem install dotcodegen 32 | ``` 33 | 34 | 2. Initialise the `.codegen` directory in your codebase: 35 | 36 | ```bash 37 | codegen --init 38 | ``` 39 | 40 | 3. Configure the templates to fit your team's needs. See the [configuration](./docs/configuration.md) section for more details. 41 | 42 | 4. Run the codegen command in your terminal: 43 | 44 | ```bash 45 | codegen path/to/the/file/you/want/to/test --openai_key 46 | ``` 47 | 48 | 49 | 5. That's it! You're ready to start generating tests for your codebase. 50 | 51 | 52 | **Extra**: 53 | 54 | This code becomes very powerful when you integrate it with your editor, so you can open a file and generate tests with a single command. 55 | 56 | In order to do that you can add a task to your `tasks.json` file in the `.vscode` directory of your project: 57 | 58 | ```json 59 | { 60 | "version": "2.0.0", 61 | "tasks": [ 62 | { 63 | "label": "Generate test for the current file", 64 | "type": "shell", 65 | // Alternatively you can specify the OPENAI_KEY environment variable in your .env file 66 | "command": "codegen ${relativeFile} --openai_key your_openai_key", 67 | "group": "test", 68 | "problemMatcher": [], 69 | "options": { 70 | "cwd": "${workspaceFolder}" 71 | }, 72 | } 73 | ] 74 | } 75 | ``` 76 | 77 | We're currently building a VSCode extension to be able to generate tests directly from your editor. Stay tuned! 78 | 79 | ## Organisation Id 80 | CLI takes an optional openai_org_id parameter. Used to specify the organisation to which the user making the API request belongs, especially in cases where the user is part of multiple organisations. This can be important for billing and access control, ensuring that the API usage is attributed to the correct organisation. 81 | ```bash 82 | codegen path/to/the/file/you/want/to/test --openai_key --openai_org_id 83 | ``` 84 | 85 | ## Linting 86 | When testing .rb code, then Rubocop or StandardRB will automatically be applied to the generated test code, if these gems are in your GemFile. For Rubocop, a .rubocop.yml needs to be on the top-level also. If both Rubocop and StandardRB are correctly set up, only StandardRB runs against the generated test code. 87 | 88 | 89 | ## How it works 90 | 91 | The extension uses AI to generate tests for any file you open in your codebase. It uses a set of customizable templates to generate the tests. You can customize the templates to fit your team's needs. 92 | 93 | ## Features 94 | 95 | - **Easy to learn**: Get started in minutes. 96 | - **AI powered scaffolding**: Generate smart tests for any file you open in your codebase. 97 | - **Fully customisable**: Customize the templates to fit your team's needs. 98 | - **Bring your own API key**: Use your own OpenAI API key to generate the tests. 99 | - **🚧 Integrates with VSCode**: Use the extension to generate tests directly from your editor. 100 | 101 | ## Contributing 102 | 103 | If you want to add some default templates for your language, feel free to open a PR. See the [config/default](./config/default) directory for the ones we have already. 104 | 105 | Bug reports and pull requests are welcome on GitHub at https://github.com/ferrucc-io/dotcodegen. 106 | 107 | ## Development 108 | 109 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 110 | 111 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 112 | 113 | 114 | ## License 115 | 116 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 117 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'dotcodegen' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require 'irb' 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install -------------------------------------------------------------------------------- /config/default/.codegen/instructions/react-vitest.md: -------------------------------------------------------------------------------- 1 | --- 2 | regex: 'src/.*\.tsx' 3 | test_file_suffix: '.test.tsx' 4 | --- 5 | 6 | 7 | When writing a test, you should follow these steps: 8 | 9 | 1. Avoid typos. 10 | 2. Avoid things that could be infinite loops. 11 | 3. This codebase is React and Typescript, try to follow the conventions and patterns of React and Typescript. 12 | 4. Avoid dangerous stuff, like things that would show up as a CVE somewhere. 13 | 5. Use vitest for tests. It's a testing library that is used in this codebase. 14 | 6. Always import tests from test-utils/ 15 | 16 | Here's the skeleton of a test: 17 | 18 | ```Typescript 19 | 20 | import "@testing-library/jest-dom"; 21 | import userEvent from "@testing-library/user-event"; 22 | import { Pagination } from "./Pagination"; 23 | import { render, screen } from "../../../../tests/testUtils"; 24 | 25 | describe("core/components/List/Pagination", () => { 26 | const mockNextPage = vi.fn(); 27 | const mockPreviousPage = vi.fn(); 28 | const setup = (currentPage = 1, lastPage = 5) => { 29 | const pagy = { 30 | page: currentPage, 31 | items: 10, 32 | count: 50, 33 | last: lastPage, 34 | }; 35 | const pagination = { 36 | currentPage: currentPage, 37 | nextPage: mockNextPage, 38 | previousPage: mockPreviousPage, 39 | }; 40 | render( 41 | , 42 | ); 43 | }; 44 | 45 | test("renders pagination component correctly", () => { 46 | setup(); 47 | expect(screen.getByText(/previous page/i)).toBeInTheDocument(); 48 | expect(screen.getByText(/next page/i)).toBeInTheDocument(); 49 | expect(screen.getByText(/1-10/i)).toBeInTheDocument(); 50 | expect(screen.getByText(/out of 50/i)).toBeInTheDocument(); 51 | }); 52 | 53 | test("disables 'Previous page' button on first page", () => { 54 | setup(1); 55 | expect(screen.getByText(/previous page/i)).toBeDisabled(); 56 | }); 57 | 58 | test("enables 'Previous page' button on page greater than 1", () => { 59 | setup(2); 60 | expect(screen.getByText(/previous page/i)).toBeEnabled(); 61 | }); 62 | 63 | test("disables 'Next page' button on last page", () => { 64 | setup(5, 5); 65 | expect(screen.getByText(/next page/i)).toBeDisabled(); 66 | }); 67 | 68 | test("enables 'Next page' button before last page", () => { 69 | setup(4, 5); 70 | expect(screen.getByText(/next page/i)).toBeEnabled(); 71 | }); 72 | 73 | test("calls nextPage function when 'Next page' button is clicked", async () => { 74 | setup(1, 5); 75 | await userEvent.click(screen.getByText(/next page/i)); 76 | expect(mockNextPage).toHaveBeenCalled(); 77 | }); 78 | 79 | test("calls previousPage function when 'Previous page' button is clicked", async () => { 80 | setup(2); 81 | await userEvent.click(screen.getByText(/previous page/i)); 82 | expect(mockPreviousPage).toHaveBeenCalled(); 83 | }); 84 | }); 85 | 86 | ``` -------------------------------------------------------------------------------- /config/default/.codegen/instructions/rspec-rails.md: -------------------------------------------------------------------------------- 1 | --- 2 | regex: 'app/.*\.rb' 3 | root_path: 'app' 4 | test_root_path: 'spec' 5 | test_file_suffix: '_spec.rb' 6 | --- 7 | 8 | When writing a test, you should follow these steps: 9 | 10 | 1. Avoid typos. 11 | 2. Avoid things that could be infinite loops. 12 | 3. This codebase is Rails, try to follow the conventions of Rails. 13 | 4. Write tests using RSpec like in the example I included 14 | 5. If you're in doubt, just write the parts you're sure of 15 | 6. No comments in the test file, just the test code 16 | 17 | Use FactoryBot factories for tests, so you should always create a factory for the model you are testing. This will help you create test data quickly and easily. 18 | 19 | Here's the skeleton of a test: 20 | 21 | ```ruby 22 | # frozen_string_literal: true 23 | 24 | require 'rails_helper' 25 | 26 | RSpec.describe __FULL_TEST_NAME__ do 27 | let(:app) { create(:app) } 28 | 29 | # Tests go here 30 | end 31 | ``` -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Introduction 4 | 5 | To make sure the tests generated by `dotcodegen` fit your team's needs, you can customize the instructions used to generate the tests. This section will guide you through the process of customizing the instructions. 6 | 7 | The instructions are markdown files in plain English. They are used to generate the tests for your codebase. You can customize the instructions to fit your team's needs. 8 | 9 | ## Writing your first instruction 10 | 11 | To write your first instruction make sure you have `codegen` installed. If you don't have it installed, follow the [installation](../README.md#installation) instructions. 12 | 13 | Create a new file in the `.codegen/instructions` directory called `react.md`. This file will contain the instructions used to generate the tests. 14 | 15 | ```bash 16 | mkdir -p .codegen/instructions 17 | touch .codegen/instructions/react.md 18 | ``` 19 | 20 | Then for the content of the file, you'll want to specify: 21 | 22 | - The regex to match the files you want to generate the test for. (e.g. `.*\.tsx` or `api/.*\.rb`) 23 | - The suffix of the test file. (e.g. `.test.tsx` or `_spec.rb`) 24 | - The instructions to generate the tests. 25 | 26 | Here's an example of a `react.md` file: 27 | 28 | ```markdown 29 | --- 30 | regex: '.*\.tsx' 31 | test_file_suffix: '.test.tsx' 32 | --- 33 | 34 | When writing a test, you should follow these steps: 35 | 36 | 1. Avoid typos. 37 | 2. Avoid things that could be infinite loops. 38 | 3. This codebase is a React codebase, try to follow the conventions of the React community. 39 | 40 | Here's an example of a good test you should reply with: 41 | 42 | 43 | 44 | ``` 45 | 46 | ## Customizing the file paths 47 | 48 | In some programming languages, tests live in specific directories. For example, in Ruby, tests live in a `spec` directory. You can specify the directory where the tests should be generated by adding a `test_file_directory` key to the instruction file. 49 | 50 | Here's an example of a `rails-controller.md` file, that specifies `spec/controllers` the directory where the tests should be generated and removes the `app/controllers` prefix from the starting file path: 51 | 52 | ```markdown 53 | --- 54 | regex: 'app/controllers/.*\.rb' 55 | root_path: 'app/controllers' 56 | test_root_path: 'spec/controllers' 57 | test_file_suffix: '_spec.rb' 58 | --- 59 | 60 | Follow these steps when writing a test for a Rails controller: 61 | 62 | 1. Avoid typos. 63 | 2. Avoid things that could be infinite loops. 64 | 3. This codebase is a Rails codebase, try to follow the conventions of the Rails community. 65 | ``` 66 | 67 | 68 | ## Instructions reference 69 | 70 | The instructions need to be .md files in the `.codegen/instructions` directory. For each instruction file, you can specify the following keys: 71 | 72 | - `regex`: The regex to match the files you want to generate the test for. (e.g. `.*\.tsx` or `api/.*\.rb`) 73 | - `test_root_path`: The root path that codegen will pre-pend to all test files. (e.g. `spec/controllers` or `api`) 74 | - `root_path`: The root path of your which you want to remove when adding the test files `test_root_path`. (e.g. `app/controllers` or `api`) 75 | - `test_file_suffix`: The suffix of the test file. (e.g. `.test.tsx` or `_spec.rb`) We will automatically add this suffix to the file name and remove the .extension from your original file name. 76 | 77 | ## The .codegen directory 78 | 79 | The `.codegen` directory is where you can customize the instructions used to generate the tests. The directory should be placed at the root of your project. 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /dotcodegen.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/dotcodegen/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'dotcodegen' 7 | spec.version = Dotcodegen::VERSION 8 | spec.authors = ['Ferruccio Balestreri'] 9 | spec.email = ['ferruccio.balestreri@gmail.com'] 10 | 11 | spec.summary = 'Generate tests for your code using LLMs.' 12 | spec.description = 'Generate tests for your code using LLMs. This gem is a CLI tool that uses OpenAI to generate test code for your code. It uses a configuration file to match files with the right test code generation instructions. It is designed to be used with Ruby on Rails, but it can be used with any codebase. It is a work in progress.' 13 | spec.homepage = 'https://github.com/ferrucc-io/dotcodegen' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = '= 3.3.0' 16 | 17 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 18 | 19 | spec.metadata['homepage_uri'] = spec.homepage 20 | spec.metadata['source_code_uri'] = 'https://github.com/ferrucc-io/dotcodegen' 21 | spec.metadata['changelog_uri'] = 'https://github.com/ferrucc-io/dotcodegen/blob/main/CHANGELOG.md' 22 | 23 | spec.files = Dir.chdir(__dir__) do 24 | `git ls-files -z`.split("\x0").reject do |f| 25 | (File.expand_path(f) == __FILE__) || 26 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .github .codegen .rspec .rubocop.yml .simplecov appveyor Gemfile Rakefile]) 27 | end 28 | end 29 | spec.bindir = 'exe' 30 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 31 | spec.require_paths = ['lib'] 32 | 33 | spec.add_dependency 'dotenv' 34 | spec.add_dependency 'front_matter_parser' 35 | spec.add_dependency 'ruby-openai' 36 | 37 | # For more information and examples about making a new gem, check out our 38 | # guide at: https://bundler.io/guides/creating_gem.html 39 | end 40 | -------------------------------------------------------------------------------- /exe/codegen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # The program gets in as an argument the full file path and the matchers config file 5 | # Usage: exe/codegen client/app/components/feature.tsx --openai_key sk42424242 6 | 7 | require_relative '../lib/dotcodegen/cli' 8 | 9 | Dotcodegen::CLI.run(ARGV) 10 | -------------------------------------------------------------------------------- /lib/dotcodegen.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'dotcodegen/version' 4 | require_relative 'dotcodegen/test_file_generator' 5 | 6 | module Dotcodegen 7 | class Error < StandardError; end 8 | end 9 | -------------------------------------------------------------------------------- /lib/dotcodegen/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'optparse' 4 | require 'ostruct' 5 | require 'front_matter_parser' 6 | require 'fileutils' 7 | require_relative 'test_file_generator' 8 | require_relative 'version' 9 | require_relative 'init' 10 | require 'dotenv' unless defined?($running_tests) && $running_tests 11 | 12 | module Dotcodegen 13 | class CLI 14 | def self.parse(args) 15 | options = OpenStruct.new 16 | opt_parser = build_option_parser(options) 17 | handle_arguments(args, opt_parser, options) 18 | options 19 | end 20 | 21 | def self.run(args) 22 | Dotenv.load unless defined?($running_tests) && $running_tests 23 | options = parse(args) 24 | return version if options.version 25 | return Init.run if options.init 26 | 27 | matchers = load_matchers('.codegen/instructions') 28 | TestFileGenerator.new(file_path: options.file_path, matchers:, openai_key: options.openai_key, openai_org_id: options.openai_org_id).run 29 | end 30 | 31 | def self.version 32 | puts "Dotcodegen version #{Dotcodegen::VERSION}" 33 | exit 34 | end 35 | 36 | def self.load_matchers(instructions_path) 37 | Dir.glob("#{instructions_path}/*.md").map do |file| 38 | parsed = FrontMatterParser::Parser.parse_file(file) 39 | parsed.front_matter.merge({ content: parsed.content }) 40 | end 41 | end 42 | 43 | # rubocop:disable Metrics/MethodLength 44 | def self.build_option_parser(options) 45 | OptionParser.new do |opts| 46 | opts.banner = 'Usage: dotcodegen [options] file_path' 47 | opts.on('--openai_key KEY', 'OpenAI API Key') { |key| options.openai_key = key } 48 | opts.on('--openai_org_id Org_Id', 'OpenAI Organisation Id') { |key| options.openai_org_id = key } 49 | opts.on('--init', 'Initialize a .codegen configuration in the current directory') { options.init = true } 50 | opts.on('--version', 'Show version') { options.version = true } 51 | opts.on_tail('-h', '--help', 'Show this message') do 52 | puts opts 53 | exit 54 | end 55 | end 56 | end 57 | 58 | # rubocop:disable Metrics/AbcSize 59 | def self.handle_arguments(args, opt_parser, options) 60 | opt_parser.parse!(args) 61 | return if options.version || options.init 62 | 63 | validate_file_path(args, opt_parser) 64 | options.file_path = args.shift 65 | options.openai_key ||= ENV['OPENAI_KEY'] 66 | options.openai_org_id ||= ENV['OPENAI_ORG_ID'] 67 | 68 | return unless options.openai_key.to_s.strip.empty? 69 | 70 | puts 'Error: Missing --openai_key flag or OPENAI_KEY environment variable.' 71 | 72 | puts opt_parser 73 | exit 1 74 | end 75 | # rubocop:enable Metrics/MethodLength 76 | # rubocop:enable Metrics/AbcSize 77 | 78 | def self.validate_file_path(args, opt_parser) 79 | return unless args.empty? 80 | 81 | puts 'Error: Missing file path.' 82 | puts opt_parser 83 | exit 1 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/dotcodegen/format_output.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dotcodegen 4 | class FormatOutput 5 | def self.format(generated_test_code) 6 | generated_test_code.gsub(/^```[a-zA-Z]*\n/, '').gsub(/\n```$/, '') 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/dotcodegen/init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dotcodegen 4 | class Init 5 | # rubocop:disable Metrics/MethodLength 6 | def self.run 7 | source_dir = File.expand_path('../../config/default/.codegen', __dir__) 8 | destination_dir = File.expand_path('.codegen', Dir.pwd) 9 | 10 | FileUtils.mkdir_p(destination_dir) 11 | FileUtils.cp_r("#{source_dir}/.", destination_dir) 12 | 13 | instructions_dir = File.expand_path('instructions', destination_dir) 14 | FileUtils.mkdir_p(instructions_dir) 15 | 16 | Dir.glob("#{source_dir}/instructions/*.md").each do |md_file| 17 | FileUtils.cp(md_file, instructions_dir) 18 | end 19 | 20 | Rails.logger.info 'Codegen initialized.' 21 | end 22 | # rubocop:enable Metrics/MethodLength 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/dotcodegen/lint_code.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dotcodegen 4 | class LintCode 5 | def initialize(file_path:) 6 | @file_path = file_path 7 | end 8 | 9 | def ruby_supported? 10 | @file_path.end_with? ".rb" 11 | end 12 | 13 | def run 14 | if ruby_supported? 15 | if gem_available?('standard') 16 | standardrb_code 17 | 18 | # Attempt to lint with RuboCop if the gem is available and .rubocop.yml exists 19 | # StandardRB includes Rubocop by default. Hence the presence of .rubocop.yml is best indication that Rubocop is in active use 20 | elsif gem_available?('rubocop') && rubocop_config_exists? 21 | rubocop_code 22 | end 23 | end 24 | end 25 | 26 | 27 | def standardrb_code 28 | puts "Linting: StandardRB" 29 | system("standardrb --fix-unsafely #{@file_path}") 30 | end 31 | 32 | def rubocop_code 33 | puts "Linting: Rubocop" 34 | system("rubocop --autocorrect-all --disable-pending-cops #{@file_path}") 35 | end 36 | 37 | def gem_available?(name) 38 | Gem::Specification.find_by_name(name) 39 | rescue Gem::LoadError 40 | false 41 | end 42 | 43 | # Function to check for .rubocop.yml configuration file 44 | def rubocop_config_exists? 45 | File.exist?('.rubocop.yml') 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/dotcodegen/test_code_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'openai' 4 | require_relative 'format_output' 5 | 6 | module Dotcodegen 7 | class TestCodeGenerator 8 | attr_reader :config, :file_to_test_path, :openai_key, :openai_org_id 9 | 10 | def initialize(config:, file_to_test_path:, openai_key:, openai_org_id: nil) 11 | @config = config 12 | @file_to_test_path = file_to_test_path 13 | @openai_key = openai_key 14 | @openai_org_id = openai_org_id 15 | end 16 | 17 | def generate_test_code 18 | response = openai_client.chat( 19 | parameters: { 20 | model: 'gpt-4-turbo-preview', 21 | messages: [{ role: 'user', content: test_prompt_text }], # Required. 22 | temperature: 0.7 23 | } 24 | ) 25 | FormatOutput.format(response.dig('choices', 0, 'message', 'content')) 26 | end 27 | 28 | def test_prompt_text 29 | [{ "type": 'text', "text": test_prompt }] 30 | end 31 | 32 | # rubocop:disable Metrics/MethodLength 33 | def test_prompt 34 | [ 35 | 'You are an expert programmer. You have been given a task to write a test file for a given file following some instructions.', 36 | 'This is the file you want to test:', 37 | '--start--', 38 | test_file_content, 39 | '--end--', 40 | 'Here are the instructions on how to write the test file:', 41 | '--start--', 42 | test_instructions, 43 | '--end--', 44 | "Your answer will be directly written in the file you want to test. Don't include any explanation or comments in your answer that isn't code.", 45 | 'You can use the comment syntax to write comments in your answer.' 46 | ].join("\n") 47 | end 48 | # rubocop:enable Metrics/MethodLength 49 | 50 | def test_file_content 51 | File.open(file_to_test_path).read 52 | end 53 | 54 | def test_instructions 55 | config['content'] 56 | end 57 | 58 | def openai_client 59 | client_options = { access_token: openai_key } 60 | client_options[:organization_id] = openai_org_id unless openai_org_id.nil? 61 | @openai_client ||= OpenAI::Client.new(client_options) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/dotcodegen/test_file_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require_relative 'test_code_generator' 5 | require_relative 'lint_code' 6 | 7 | module Dotcodegen 8 | class TestFileGenerator 9 | attr_reader :file_path, :matchers, :openai_key, :openai_org_id 10 | 11 | def initialize(file_path:, matchers:, openai_key:, openai_org_id: nil) 12 | @file_path = file_path 13 | @matchers = matchers 14 | @openai_key = openai_key 15 | @openai_org_id = openai_org_id 16 | end 17 | 18 | def run 19 | puts "Finding matcher for #{file_path}..." 20 | return puts "No matcher found for #{file_path}" unless matcher 21 | 22 | puts "Test file path: #{test_file_path}" 23 | ensure_test_file_presence 24 | 25 | write_generated_code_to_test_file 26 | open_test_file_in_editor unless $running_tests 27 | 28 | puts 'Running codegen...' 29 | end 30 | 31 | def ensure_test_file_presence 32 | puts "Creating test file if it doesn't exist..." 33 | return if File.exist?(test_file_path) 34 | 35 | FileUtils.mkdir_p(File.dirname(test_file_path)) 36 | File.write(test_file_path, '') 37 | end 38 | 39 | 40 | def write_generated_code_to_test_file 41 | generated_code = Dotcodegen::TestCodeGenerator.new(config: matcher, 42 | file_to_test_path: file_path, 43 | openai_key:, 44 | openai_org_id:).generate_test_code 45 | File.write(test_file_path, generated_code) 46 | 47 | Dotcodegen::LintCode.new(file_path: test_file_path).run 48 | end 49 | 50 | def open_test_file_in_editor 51 | system("code #{test_file_path}") 52 | end 53 | 54 | def matcher 55 | @matcher ||= matchers.find { |m| file_path.match?(m['regex']) } 56 | end 57 | 58 | def test_file_path 59 | @test_file_path ||= "#{test_root_path}#{relative_file_name}#{test_file_suffix}" 60 | end 61 | 62 | def relative_file_name 63 | file_path.sub(root_path, '').sub(/\.\w+$/, '') 64 | end 65 | 66 | def test_root_path 67 | matcher['test_root_path'] || '' 68 | end 69 | 70 | def root_path 71 | matcher['root_path'] || '' 72 | end 73 | 74 | def test_file_suffix 75 | matcher['test_file_suffix'] || '' 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/dotcodegen/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dotcodegen 4 | VERSION = '0.1.5' 5 | end 6 | -------------------------------------------------------------------------------- /sig/dotcodegen.rbs: -------------------------------------------------------------------------------- 1 | module Dotcodegen 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /spec/codegen_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dotcodegen/test_file_generator' 4 | 5 | RSpec.describe 'Main Script' do 6 | let(:file_path) { 'client/app/components/feature.tsx' } 7 | let(:openai_key) { 'test_openai_key' } 8 | let(:codegen_instance) { instance_double(Dotcodegen::TestFileGenerator) } 9 | 10 | before do 11 | allow(Dotcodegen::TestFileGenerator).to receive(:new).and_return(codegen_instance) 12 | allow(codegen_instance).to receive(:run) 13 | end 14 | 15 | it 'initializes and runs the TestFileGenerator with the provided file path, matchers including content, and openai key' do 16 | ARGV.replace([file_path, '--openai_key', openai_key]) 17 | load 'exe/codegen' 18 | expect(Dotcodegen::TestFileGenerator).to have_received(:new).with(hash_including(file_path: file_path, openai_key: openai_key)) 19 | expect(codegen_instance).to have_received(:run) 20 | end 21 | 22 | it 'initializes and runs the TestFileGenerator with the provided file path, matchers including content, openai key, and optional openai org id' do 23 | openai_org_id = 'test_openai_org_id' 24 | ARGV.replace([file_path, '--openai_key', openai_key, '--openai_org_id', openai_org_id]) 25 | load 'exe/codegen' 26 | expect(Dotcodegen::TestFileGenerator).to have_received(:new).with(hash_including(file_path: file_path, openai_key: openai_key, openai_org_id: openai_org_id)) 27 | expect(codegen_instance).to have_received(:run) 28 | end 29 | 30 | it 'exits with an error message when the openai_key flag is missing and OPENAI_KEY environment variable is not set' do 31 | ARGV.replace([file_path]) 32 | expect { load 'exe/codegen' } 33 | .to output(a_string_including("Error: Missing --openai_key flag or OPENAI_KEY environment variable.")).to_stdout 34 | .and raise_error(SystemExit) 35 | end 36 | 37 | context 'when the openai_key flag is missing and the OPENAI_KEY environment variable is set' do 38 | before do 39 | ENV['OPENAI_KEY'] = openai_key 40 | end 41 | 42 | it 'initializes and runs the TestFileGenerator with the provided file path, matchers including content, and openai key' do 43 | ARGV.replace([file_path]) 44 | load 'exe/codegen' 45 | expect(Dotcodegen::TestFileGenerator).to have_received(:new).with(hash_including(file_path: file_path, openai_key: openai_key)) 46 | expect(codegen_instance).to have_received(:run) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/dotcodegen/format_output_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dotcodegen/format_output' 4 | 5 | RSpec.describe Dotcodegen::FormatOutput do 6 | describe '.format_test_code' do 7 | it 'formats the generated test code by removing markdown code block syntax and leading/trailing spaces' do 8 | expect(Dotcodegen::FormatOutput.format("```ruby\nformatted_code\n```")).to eq('formatted_code') 9 | end 10 | 11 | it 'formats typescript code' do 12 | expect(Dotcodegen::FormatOutput.format("```typescript\nformatted_code\n```")).to eq('formatted_code') 13 | end 14 | 15 | it 'formats python code' do 16 | expect(Dotcodegen::FormatOutput.format("```python\nformatted_code\n```")).to eq('formatted_code') 17 | end 18 | 19 | context 'when the test code does not contain markdown code block syntax' do 20 | it 'returns the test code as is' do 21 | expect(Dotcodegen::FormatOutput.format('formatted_code')).to eq('formatted_code') 22 | end 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /spec/dotcodegen/lint_code_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dotcodegen/lint_code' 4 | require 'tempfile' 5 | 6 | RSpec.describe Dotcodegen::LintCode do 7 | let!(:file_path) { 'path/to/test/file.rb' } 8 | subject(:lint_code) { described_class.new(file_path:) } 9 | 10 | before do 11 | allow(Gem::Specification).to receive(:find_by_name).and_call_original 12 | allow(lint_code).to receive(:system) 13 | end 14 | 15 | 16 | describe '#run' do 17 | context 'when StandardRB gem only is available' do 18 | it 'runs StandardRB linting command' do 19 | allow(lint_code).to receive(:gem_available?).with('standard').and_return(true) 20 | allow(lint_code).to receive(:gem_available?).with('rubocop').and_return(false) 21 | expect(lint_code).to receive(:system).with(any_args).exactly(1).times 22 | lint_code.run 23 | end 24 | end 25 | 26 | context 'when RuboCop gem only is available and .rubocop.yml exists' do 27 | it 'runs RuboCop linting command' do 28 | allow(lint_code).to receive(:gem_available?).with('standard').and_return(false) 29 | allow(lint_code).to receive(:gem_available?).with('rubocop').and_return(true) 30 | allow(lint_code).to receive(:rubocop_config_exists?).and_return(true) 31 | expect(lint_code).to receive(:system).with(any_args).exactly(1).times 32 | lint_code.run 33 | end 34 | end 35 | 36 | context 'when RuboCop gem only is available but .rubocop.yml does not exists' do 37 | it 'runs RuboCop linting command' do 38 | allow(lint_code).to receive(:gem_available?).with('standard').and_return(false) 39 | allow(lint_code).to receive(:gem_available?).with('rubocop').and_return(true) 40 | allow(lint_code).to receive(:rubocop_config_exists?).and_return(false) 41 | expect(lint_code).not_to receive(:system) 42 | lint_code.run 43 | end 44 | end 45 | 46 | context 'when both RuboCop(with its config file) and StandardRB Exists' do 47 | it 'runs only one of them' do 48 | allow(lint_code).to receive(:gem_available?).with('standard').and_return(true) 49 | allow(lint_code).to receive(:gem_available?).with('rubocop').and_return(true) 50 | allow(lint_code).to receive(:rubocop_config_exists?).and_return(true) 51 | expect(lint_code).to receive(:system).with(any_args).exactly(1).time 52 | lint_code.run 53 | end 54 | end 55 | 56 | context 'when neither RuboCop or Standard Exist' do 57 | it 'runs both linting commands' do 58 | allow(lint_code).to receive(:gem_available?).with(any_args).and_return(false) 59 | expect(lint_code).to receive(:system).with(any_args).exactly(0).times 60 | lint_code.run 61 | end 62 | end 63 | end 64 | 65 | describe '#gem_available?' do 66 | context 'when a gem is available' do 67 | it 'returns true' do 68 | allow(Gem::Specification).to receive(:find_by_name).with('standard').and_return(true) 69 | expect(lint_code.gem_available?('standard')).to be true 70 | end 71 | end 72 | 73 | context 'when a gem is not available' do 74 | it 'returns false' do 75 | allow(Gem::Specification).to receive(:find_by_name).with('nonexistent').and_raise(Gem::LoadError) 76 | expect(lint_code.gem_available?('nonexistent')).to be false 77 | end 78 | end 79 | end 80 | 81 | describe '#rubocop_config_exists?' do 82 | it 'returns true if .rubocop.yml exists' do 83 | allow(File).to receive(:exist?).with('.rubocop.yml').and_return(true) 84 | expect(lint_code.rubocop_config_exists?).to be true 85 | end 86 | 87 | it 'returns false if .rubocop.yml does not exist' do 88 | allow(File).to receive(:exist?).with('.rubocop.yml').and_return(false) 89 | expect(lint_code.rubocop_config_exists?).to be false 90 | end 91 | end 92 | 93 | describe '#ruby_supported?' do 94 | let(:ruby_file_checker) { described_class.new(file_path: 'example.rb') } 95 | let(:non_ruby_file_checker) { described_class.new(file_path: 'example.txt') } 96 | 97 | it 'returns true for a file path ending with .rb' do 98 | expect(ruby_file_checker.ruby_supported?).to be(true) 99 | end 100 | 101 | it 'returns false for a file path not ending with .rb' do 102 | expect(non_ruby_file_checker.ruby_supported?).to be(false) 103 | end 104 | end 105 | 106 | describe '#lints_file_as_expected' do 107 | # Rubucop likes to put magic comment # frozen_string_literal: true at the top of files for performace 108 | # Rubucop likes single quotes be used when string interpolation is not needed 109 | # Standardrb instead, likes double quotes always for strings 110 | it "standardrb" do 111 | Tempfile.create(['tempfile_', '.rb']) do |tempfile| 112 | initial_content = <<~RUBY 113 | greeting = "Hello, world!" 114 | name = "Alice" 115 | puts "\#{greeting} I'm \#{name}." 116 | RUBY 117 | 118 | expected_content = <<~RUBY 119 | greeting = "Hello, world!" 120 | name = "Alice" 121 | puts "\#{greeting} I'm \#{name}." 122 | RUBY 123 | 124 | tempfile.write(initial_content) 125 | tempfile.close 126 | 127 | instance = described_class.new(file_path: tempfile.path) 128 | 129 | allow(instance).to receive(:gem_available?).with('rubocop').and_return(false) 130 | allow(instance).to receive(:gem_available?).with('standard').and_return(true) 131 | 132 | instance.run 133 | 134 | actual_content = File.read(tempfile.path) 135 | expect(actual_content).to eq(expected_content) 136 | end 137 | end 138 | 139 | it "standardrb but on none ruby file" do 140 | Tempfile.create(['tempfile_', '.js']) do |tempfile| 141 | initial_content = <<~RUBY 142 | greeting = "Hello, world!" 143 | name = "Alice" 144 | puts "\#{greeting} I'm \#{name}." 145 | RUBY 146 | 147 | tempfile.write(initial_content) 148 | tempfile.close 149 | 150 | instance = described_class.new(file_path: tempfile.path) 151 | 152 | allow(instance).to receive(:gem_available?).with('rubocop').and_return(false) 153 | allow(instance).to receive(:gem_available?).with('standard').and_return(true) 154 | 155 | instance.run 156 | 157 | actual_content = File.read(tempfile.path) 158 | expect(actual_content).to eq(initial_content) 159 | end 160 | end 161 | 162 | it "rubocop" do 163 | Tempfile.create(['tempfile_', '.rb']) do |tempfile| 164 | initial_content = <<~RUBY 165 | greeting = "Hello, world!" 166 | name = "Alice" 167 | puts "\#{greeting} I'm \#{name}." 168 | RUBY 169 | 170 | expected_content = <<~RUBY 171 | \# frozen_string_literal: true 172 | 173 | greeting = 'Hello, world!' 174 | name = 'Alice' 175 | puts "\#{greeting} I'm \#{name}." 176 | RUBY 177 | 178 | tempfile.write(initial_content) 179 | tempfile.close 180 | 181 | instance = described_class.new(file_path: tempfile.path) 182 | 183 | allow(instance).to receive(:gem_available?).with('rubocop').and_return(true) 184 | allow(instance).to receive(:gem_available?).with('standard').and_return(false) 185 | allow(instance).to receive(:rubocop_config_exists?).and_return(true) 186 | 187 | instance.run 188 | 189 | actual_content = File.read(tempfile.path) 190 | expect(actual_content).to eq(expected_content) # Use strip to remove any leading/trailing whitespace 191 | end 192 | end 193 | end 194 | end -------------------------------------------------------------------------------- /spec/dotcodegen/test_code_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dotcodegen/test_code_generator' 4 | 5 | RSpec.describe Dotcodegen::TestCodeGenerator do 6 | let(:config) do 7 | { 8 | 'regex' => 'spec/fixtures/.*\.rb', 9 | 'test_file_suffix' => '_spec.rb', 10 | 'content' => 'Be a very good coder and write a very good test' 11 | } 12 | end 13 | let(:file_to_test_path) { 'spec/fixtures/labels_controller.rb' } 14 | let(:openai_key) { 'test_openai_key' } 15 | let(:code_generator_instance) do 16 | described_class.new(config:, file_to_test_path:, openai_key:) 17 | end 18 | 19 | describe '#test_prompt' do 20 | it 'returns a correctly formatted prompt with file content and instructions' do 21 | file_content = File.read(file_to_test_path) 22 | instructions_content = config['content'] 23 | expected_prompt = [ 24 | 'You are an expert programmer. You have been given a task to write a test file for a given file following some instructions.', 25 | 'This is the file you want to test:', 26 | '--start--', 27 | file_content, 28 | '--end--', 29 | 'Here are the instructions on how to write the test file:', 30 | '--start--', 31 | instructions_content, 32 | '--end--', 33 | "Your answer will be directly written in the file you want to test. Don't include any explanation or comments in your answer that isn't code.", 34 | 'You can use the comment syntax to write comments in your answer.' 35 | ].join("\n") 36 | 37 | expect(code_generator_instance.test_prompt).to eq(expected_prompt) 38 | end 39 | end 40 | 41 | describe '#generate_test_code' do 42 | it 'mocks the OpenAI response and checks the generated code' do 43 | mock_response = { 'choices' => [{ 'message' => { 'content' => 'Mocked generated code' } }] } 44 | allow_any_instance_of(OpenAI::Client).to receive(:chat).and_return(mock_response) 45 | expect(code_generator_instance.generate_test_code).to eq('Mocked generated code') 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/dotcodegen/test_file_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dotcodegen/test_file_generator' 4 | 5 | RSpec.describe Dotcodegen::TestFileGenerator do 6 | let(:file_path) { 'client/app/components/feature.tsx' } 7 | let(:api_matcher) do 8 | { 9 | 'regex' => 'api/.*\.rb', 10 | 'root_path' => 'api/app/', 11 | 'test_root_path' => 'api/spec/', 12 | 'test_file_suffix' => '_spec.rb' 13 | } 14 | end 15 | let(:client_matcher) do 16 | { 17 | 'regex' => 'client/app/.*\.tsx', 18 | 'test_file_suffix' => '.test.tsx' 19 | } 20 | end 21 | let(:matchers) { [api_matcher, client_matcher] } 22 | let(:openai_key) { 'test_openai_key' } 23 | let(:codegen_instance) { instance_double(Dotcodegen::TestFileGenerator) } 24 | 25 | subject { described_class.new(file_path:, matchers:, openai_key:) } 26 | 27 | describe '#run' do 28 | after(:each) { FileUtils.remove_dir('client/', force: true) } 29 | let(:file_path) { 'spec/fixtures/feature.tsx' } 30 | let(:client_matcher) do 31 | { 32 | 'regex' => 'spec/fixtures/.*\.tsx', 33 | 'test_file_suffix' => '.test.tsx', 34 | 'root_path' => 'spec/fixtures/', 35 | 'test_root_path' => 'tmp/codegen_spec/', 36 | 'instructions' => 'instructions/react.md' 37 | } 38 | end 39 | 40 | context 'when test file does not exist' do 41 | it 'creates a test file and writes generated code once' do 42 | allow(File).to receive(:exist?).with('tmp/codegen_spec/feature.test.tsx').and_return(false) 43 | expect(FileUtils).to receive(:mkdir_p).with('tmp/codegen_spec') 44 | allow(Dotcodegen::TestCodeGenerator).to receive_message_chain(:new, :generate_test_code).and_return('Mocked generated code') 45 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.tsx', '').once 46 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.tsx', 'Mocked generated code').once 47 | subject.run 48 | end 49 | end 50 | 51 | context 'when test file already exists' do 52 | it 'does not create a test file but writes generated code' do 53 | allow(File).to receive(:exist?).with('tmp/codegen_spec/feature.test.tsx').and_return(true) 54 | expect(FileUtils).not_to receive(:mkdir_p) 55 | allow(Dotcodegen::TestCodeGenerator).to receive_message_chain(:new, :generate_test_code).and_return('Mocked generated code') 56 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.tsx', 'Mocked generated code').once 57 | subject.run 58 | end 59 | end 60 | end 61 | 62 | describe '#run with linting' do 63 | after(:each) { FileUtils.remove_dir('client/', force: true) } 64 | let(:file_path) { 'spec/fixtures/feature.rb' } 65 | let(:client_matcher) do 66 | { 67 | 'regex' => 'spec/fixtures/.*\.rb', 68 | 'test_file_suffix' => '.test.rb', 69 | 'root_path' => 'spec/fixtures/', 70 | 'test_root_path' => 'tmp/codegen_spec/', 71 | 'instructions' => 'instructions/ruby.md' 72 | } 73 | end 74 | 75 | context 'when test file does not exist' do 76 | it 'creates a test file and writes generated code once' do 77 | allow(File).to receive(:exist?).with(any_args).and_return(false) 78 | expect(FileUtils).to receive(:mkdir_p).with('tmp/codegen_spec') 79 | allow(Dotcodegen::TestCodeGenerator).to receive_message_chain(:new, :generate_test_code).and_return('Mocked generated code') 80 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.rb', '').once 81 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.rb', 'Mocked generated code').once 82 | subject.run 83 | end 84 | end 85 | 86 | context 'when test file already exists' do 87 | it 'does not create a test file but writes generated code' do 88 | allow(File).to receive(:exist?).with(".rubocop.yml").and_return(false) 89 | allow(File).to receive(:exist?).with('tmp/codegen_spec/feature.test.rb').and_return(true) 90 | expect(FileUtils).not_to receive(:mkdir_p) 91 | allow(Dotcodegen::TestCodeGenerator).to receive_message_chain(:new, :generate_test_code).and_return('Mocked generated code') 92 | expect(File).to receive(:write).with('tmp/codegen_spec/feature.test.rb', 'Mocked generated code').once 93 | subject.run 94 | end 95 | end 96 | end 97 | 98 | describe '#matcher' do 99 | it 'returns the matching regex for the frontend' do 100 | expect(subject.matcher).to eq(client_matcher) 101 | end 102 | 103 | context 'when file path is a ruby file' do 104 | let(:file_path) { 'api/app/models/app.rb' } 105 | it 'returns the matching regex for the backend' do 106 | expect(subject.matcher).to eq(api_matcher) 107 | end 108 | end 109 | 110 | context 'when there are no matches' do 111 | let(:file_path) { 'terraform/models/app.rb' } 112 | it 'returns nil' do 113 | expect(subject.matcher).to be_nil 114 | end 115 | end 116 | 117 | context 'when file path does not match any regex' do 118 | let(:file_path) { 'api/models/app.go' } 119 | it 'returns nil' do 120 | expect(subject.matcher).to be_nil 121 | end 122 | end 123 | end 124 | 125 | describe '#test_file_path' do 126 | it 'returns the test file path for the frontend' do 127 | expect(subject.test_file_path).to eq('client/app/components/feature.test.tsx') 128 | end 129 | 130 | context 'when file path is a ruby file' do 131 | let(:file_path) { 'api/app/models/app.rb' } 132 | it 'returns the test file path for the backend' do 133 | expect(subject.test_file_path).to eq('api/spec/models/app_spec.rb') 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/dotcodegen_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dotcodegen do 4 | it 'has a version number' do 5 | expect(Dotcodegen::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/fixtures/.codegen/rails.md: -------------------------------------------------------------------------------- 1 | --- 2 | regex: 'api/.*\.rb' 3 | root_path: 'api/app' 4 | test_root_path: 'api/spec' 5 | test_file_suffix: '_spec.rb' 6 | --- 7 | 8 | When writing a test, you should follow these steps: 9 | 10 | 1. Avoid typos. 11 | 2. Avoid things that could be infinite loops. 12 | 3. This codebase is Rails, try to follow the conventions of Rails. 13 | 4. Avoid dangerous stuff, like things that would show up as a CVE somewhere. 14 | 15 | Use FactoryBot factories for tests, so you should always create a factory for the model you are testing. This will help you create test data quickly and easily. 16 | 17 | Here's the skeleton of a test: 18 | 19 | ```ruby 20 | # frozen_string_literal: true 21 | 22 | require 'rails_helper' 23 | 24 | RSpec.describe __FULL_TEST_NAME__ do 25 | let(:app) { create(:app) } 26 | 27 | # Tests go here 28 | end 29 | ``` -------------------------------------------------------------------------------- /spec/fixtures/.codegen/react.md: -------------------------------------------------------------------------------- 1 | --- 2 | regex: 'client/.*\.tsx' 3 | test_file_suffix: '.test.tsx' 4 | instructions: '.codegen/instructions/react.md' 5 | --- 6 | 7 | When writing a test, you should follow these steps: 8 | 9 | 1. Avoid typos. 10 | 2. Avoid things that could be infinite loops. 11 | 3. This codebase is React and Typescript, try to follow the conventions and patterns of React and Typescript. 12 | 4. Avoid dangerous stuff, like things that would show up as a CVE somewhere. 13 | 5. Use vitest for tests. It's a testing library that is used in this codebase. 14 | 6. Always import tests from test-utils/ 15 | 16 | Here's the skeleton of a test: 17 | 18 | ```Typescript 19 | 20 | import "@testing-library/jest-dom"; 21 | import userEvent from "@testing-library/user-event"; 22 | import { Pagination } from "./Pagination"; 23 | import { render, screen } from "../../../../tests/testUtils"; 24 | 25 | describe("core/components/List/Pagination", () => { 26 | const mockNextPage = vi.fn(); 27 | const mockPreviousPage = vi.fn(); 28 | const setup = (currentPage = 1, lastPage = 5) => { 29 | const pagy = { 30 | page: currentPage, 31 | items: 10, 32 | count: 50, 33 | last: lastPage, 34 | }; 35 | const pagination = { 36 | currentPage: currentPage, 37 | nextPage: mockNextPage, 38 | previousPage: mockPreviousPage, 39 | }; 40 | render( 41 | , 42 | ); 43 | }; 44 | 45 | test("renders pagination component correctly", () => { 46 | setup(); 47 | expect(screen.getByText(/previous page/i)).toBeInTheDocument(); 48 | expect(screen.getByText(/next page/i)).toBeInTheDocument(); 49 | expect(screen.getByText(/1-10/i)).toBeInTheDocument(); 50 | expect(screen.getByText(/out of 50/i)).toBeInTheDocument(); 51 | }); 52 | 53 | test("disables 'Previous page' button on first page", () => { 54 | setup(1); 55 | expect(screen.getByText(/previous page/i)).toBeDisabled(); 56 | }); 57 | 58 | test("enables 'Previous page' button on page greater than 1", () => { 59 | setup(2); 60 | expect(screen.getByText(/previous page/i)).toBeEnabled(); 61 | }); 62 | 63 | test("disables 'Next page' button on last page", () => { 64 | setup(5, 5); 65 | expect(screen.getByText(/next page/i)).toBeDisabled(); 66 | }); 67 | 68 | test("enables 'Next page' button before last page", () => { 69 | setup(4, 5); 70 | expect(screen.getByText(/next page/i)).toBeEnabled(); 71 | }); 72 | 73 | test("calls nextPage function when 'Next page' button is clicked", async () => { 74 | setup(1, 5); 75 | await userEvent.click(screen.getByText(/next page/i)); 76 | expect(mockNextPage).toHaveBeenCalled(); 77 | }); 78 | 79 | test("calls previousPage function when 'Previous page' button is clicked", async () => { 80 | setup(2); 81 | await userEvent.click(screen.getByText(/previous page/i)); 82 | expect(mockPreviousPage).toHaveBeenCalled(); 83 | }); 84 | }); 85 | 86 | ``` -------------------------------------------------------------------------------- /spec/fixtures/feature.rb: -------------------------------------------------------------------------------- 1 | class Feature 2 | def self.hello 3 | "Hi" 4 | end 5 | end -------------------------------------------------------------------------------- /spec/fixtures/feature.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { IAlert } from 'core/types/Alert'; 3 | import Icon from 'core/design-system/components/Icon'; 4 | import { ListItem, Select, Text, Tooltip, UnorderedList } from '@chakra-ui/react'; 5 | 6 | const FREQUENCIES = [ 7 | { value: "0", label: "Daily" }, 8 | { value: "1", label: "Weekly" }, 9 | { value: "2", label: "Monthly" }, 10 | ]; 11 | 12 | function TooltipBody() { 13 | return ( 14 |
15 | 16 | Daily: Every weekday at 9am UTC 17 | Weekly: Every Monday at 9am UTC 18 | Monthly: Start of the month at 9am UTC 19 | 20 |
21 | ); 22 | } 23 | 24 | export const Frequency: React.FC<{ 25 | alertChanges: IAlert; 26 | setAlertChanges: (alert: IAlert) => void; 27 | }> = ({ setAlertChanges, alertChanges }) => { 28 | const handleFrequencyChange = (e: React.ChangeEvent) => { 29 | const value = parseInt(e.target.value, 10); 30 | setAlertChanges({ 31 | ...alertChanges, 32 | frequency: value as 0 | 1 | 2, 33 | }); 34 | }; 35 | 36 | return ( 37 |
38 |
39 | 40 | Frequency 41 | 42 | } placement="right"> 43 |
44 | 45 |
46 |
47 |
48 | 49 | How frequently you wish to receive the graph 50 | 51 | 69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /spec/fixtures/labels_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class LabelsController < ApplicationController 4 | before_action :verify_user_has_permission_for_app 5 | 6 | def index 7 | @labels = Label.where(app:).limit(100) 8 | end 9 | 10 | def show 11 | @label = Label.find_by(id: params[:id], app:) 12 | render json: { error: 'Label not found' }, status: :not_found unless @label 13 | end 14 | 15 | def create 16 | @label = Labels::Create.run!(color: params[:color], name: params[:name], app_id: app.id) 17 | render :show 18 | end 19 | 20 | def update 21 | @label = Labels::Update.run!(app_id: app.id, id: params[:id], name: params[:name], color: params[:color]) 22 | render :show 23 | end 24 | 25 | def destroy 26 | Labels::Destroy.run!(id: params[:id], app_id: app.id) 27 | render json: { status: 200, message: 'Label deleted successfully' } 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | require 'dotcodegen' 5 | 6 | SimpleCov.start 7 | 8 | RSpec.configure do |config| 9 | # Enable flags like --only-failures and --next-failure 10 | config.example_status_persistence_file_path = '.rspec_status' 11 | 12 | # Disable RSpec exposing methods globally on `Module` and `main` 13 | config.disable_monkey_patching! 14 | 15 | config.expect_with :rspec do |c| 16 | c.syntax = :expect 17 | end 18 | 19 | config.before(:suite) do 20 | $running_tests = true 21 | end 22 | end 23 | --------------------------------------------------------------------------------