# frozen_string_literal: true require 'fast_spec_helper' require 'tty/prompt/test' require_relative '../../../scripts/internal_events/cli' RSpec.describe Cli, feature_category: :service_ping do let(:prompt) { TTY::Prompt::Test.new } let(:files_to_cleanup) { [] } let(:event1_filepath) { 'config/events/internal_events_cli_used.yml' } let(:event1_content) { internal_event_fixture('events/event_with_identifiers.yml') } let(:event2_filepath) { 'ee/config/events/internal_events_cli_opened.yml' } let(:event2_content) { internal_event_fixture('events/ee_event_without_identifiers.yml') } let(:event3_filepath) { 'config/events/internal_events_cli_closed.yml' } let(:event3_content) { internal_event_fixture('events/secondary_event_with_identifiers.yml') } before do stub_milestone('16.6') collect_file_writes(files_to_cleanup) stub_product_groups(File.read('spec/fixtures/scripts/internal_events/stages.yml')) stub_helper(:fetch_window_size, '50') end after do delete_files(files_to_cleanup) end shared_examples 'creates the right defintion files' do |description, test_case = {}| # For expected keystroke mapping, see https://github.com/piotrmurach/tty-reader/blob/master/lib/tty/reader/keys.rb let(:keystrokes) { test_case.dig('inputs', 'keystrokes') || [] } let(:input_files) { test_case.dig('inputs', 'files') || [] } let(:output_files) { test_case.dig('outputs', 'files') || [] } subject { run_with_verbose_timeout } it "in scenario: #{description}" do delete_old_ouputs # just in case prep_input_files queue_cli_inputs(keystrokes) expect_file_creation subject end private def delete_old_ouputs [input_files, output_files].flatten.each do |file_info| FileUtils.rm_f(Rails.root.join(file_info['path'])) end end def prep_input_files input_files.each do |file| File.write( Rails.root.join(file['path']), File.read(Rails.root.join(file['content'])) ) end end def expect_file_creation if output_files.any? output_files.each do |file| expect(File).to receive(:write).with(file['path'], File.read(file['content'])) end else expect(File).not_to receive(:write) end end end context 'when creating new events' do YAML.safe_load(File.read('spec/fixtures/scripts/internal_events/new_events.yml')).each do |test_case| it_behaves_like 'creates the right defintion files', test_case['description'], test_case end end context 'when creating new metrics' do YAML.safe_load(File.read('spec/fixtures/scripts/internal_events/new_metrics.yml')).each do |test_case| it_behaves_like 'creates the right defintion files', test_case['description'], test_case end context 'when creating a metric from multiple events' do let(:events) do [{ action: '00_event1', category: 'InternalEventTracking', product_section: 'dev', product_stage: 'plan', product_group: 'optimize' }, { action: '00_event2', category: 'InternalEventTracking', product_section: 'dev', product_stage: 'create', product_group: 'ide' }, { action: '00_event3', category: 'InternalEventTracking', product_section: 'dev', product_stage: 'create', product_group: 'source_code' }] end before do events.each do |event| File.write("config/events/#{event[:action]}.yml", event.transform_keys(&:to_s).to_yaml) end end it 'filters the product group options based on common section' do # Select 00_event1 & #00_event2 queue_cli_inputs([ "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions " ", # Multi-select: __event1 "\e[B", # Arrow down to: __event2 " ", # Multi-select: __event2 "\n", # Submit selections "\n", # Select: Weekly/Monthly count of unique users "aggregate metric description\n", # Submit description "\n", # Accept description for weekly "\n" # Copy & continue ]) run_with_timeout # Filter down to "dev" options expect(plain_last_lines(9)).to eq <<~TEXT.chomp ‣ dev:plan:project_management dev:plan:product_planning dev:plan:knowledge dev:plan:optimize dev:create:source_code dev:create:code_review dev:create:ide dev:create:editor_extensions dev:create:code_creation TEXT end it 'filters the product group options based on common section & stage' do # Select 00_event2 & #00_event3 queue_cli_inputs([ "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions "\e[B", # Arrow down to: __event2 " ", # Multi-select: __event2 "\e[B", # Arrow down to: __event3 " ", # Multi-select: __event3 "\n", # Submit selections "\n", # Select: Weekly/Monthly count of unique users "aggregate metric description\n", # Submit description "\n", # Accept description for weekly "\n" # Copy & continue ]) run_with_timeout # Filter down to "dev:create" options expect(plain_last_lines(5)).to eq <<~TEXT.chomp ‣ dev:create:source_code dev:create:code_review dev:create:ide dev:create:editor_extensions dev:create:code_creation TEXT end end context 'when product group for event no longer exists' do let(:event) do { action: '00_event1', category: 'InternalEventTracking', product_section: 'other', product_stage: 'other', product_group: 'other' } end before do File.write("config/events/#{event[:action]}.yml", event.transform_keys(&:to_s).to_yaml) end it 'prompts user to select another group' do queue_cli_inputs([ "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction "\n", # Select: 00__event1 "\n", # Select: Weekly/Monthly count of unique users "aggregate metric description\n", # Submit description "\n", # Accept description for weekly "2\n" # Modify attributes ]) run_with_timeout # Filter down to "dev" options expect(plain_last_lines(50)).to include 'Select one: Which group owns the metric?' end end context 'when creating a metric for an event which has metrics' do before do File.write(event1_filepath, File.read(event1_content)) end it 'shows all metrics options' do select_event_from_list expect(plain_last_lines(5)).to eq <<~TEXT.chomp ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] Monthly/Weekly count of [internal_events_cli_used occurrences] Total count of [internal_events_cli_used occurrences] TEXT end context 'with an existing weekly metric' do before do File.write( 'ee/config/metrics/counts_7d/count_total_internal_events_cli_used_weekly.yml', File.read('spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml') ) end it 'partially filters metric options' do select_event_from_list expect(plain_last_lines(6)).to eq <<~TEXT.chomp ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] Monthly count of [internal_events_cli_used occurrences] ✘ Weekly count of [internal_events_cli_used occurrences] (already defined) Total count of [internal_events_cli_used occurrences] TEXT end end context 'with an existing total metric' do before do File.write( 'ee/config/metrics/counts_all/count_total_internal_events_cli_used.yml', File.read('spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml') ) end it 'filters whole metric options' do select_event_from_list expect(plain_last_lines(5)).to eq <<~TEXT.chomp ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] Monthly/Weekly count of [internal_events_cli_used occurrences] ✘ Total count of [internal_events_cli_used occurrences] (already defined) TEXT end end private def select_event_from_list queue_cli_inputs([ "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction 'internal_events_cli_used', # Filters to this event "\n" # Select: config/events/internal_events_cli_used.yml ]) run_with_timeout end end context 'when event excludes identifiers' do before do File.write(event2_filepath, File.read(event2_content)) end it 'filters unavailable identifiers' do queue_cli_inputs([ "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction 'internal_events_cli_opened', # Filters to this event "\n" # Select: config/events/internal_events_cli_opened.yml ]) run_with_timeout expect(plain_last_lines(5)).to eq <<~TEXT.chomp ✘ Monthly/Weekly count of unique users [who triggered internal_events_cli_opened] (user unavailable) ✘ Monthly/Weekly count of unique projects [where internal_events_cli_opened occurred] (project unavailable) ✘ Monthly/Weekly count of unique namespaces [where internal_events_cli_opened occurred] (namespace unavailable) ‣ Monthly/Weekly count of [internal_events_cli_opened occurrences] Total count of [internal_events_cli_opened occurrences] TEXT end end context 'when all metrics already exist' do let(:event) { { action: '00_event1', category: 'InternalEventTracking' } } let(:metric) { { options: { 'events' => ['00_event1'] }, events: [{ 'name' => '00_event1' }] } } let(:files) do [ ['config/events/00_event1.yml', event], ['config/metrics/counts_all/count_total_00_event1.yml', metric.merge(time_frame: 'all')], ['config/metrics/counts_7d/count_total_00_event1_weekly.yml', metric.merge(time_frame: '7d')], ['config/metrics/counts_28d/count_total_00_event1_monthly.yml', metric.merge(time_frame: '28d')] ] end before do files.each do |path, content| File.write(path, content.transform_keys(&:to_s).to_yaml) end end it 'exits the script and directs user to search for existing metrics' do queue_cli_inputs([ "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction '00_event1', # Filters to this event "\n" # Select: config/events/00_event1.yml ]) run_with_timeout expect(plain_last_lines(15)).to include 'Looks like the potential metrics for this event ' \ 'either already exist or are unsupported.' end end end context 'when showing usage examples' do let(:expected_example_prompt) do <<~TEXT.chomp Select one: Select a use-case to view examples for: (Press ↑/↓ arrow or 1-8 number to move and Enter to select) ‣ 1. ruby/rails 2. rspec 3. javascript (vue) 4. javascript (plain) 5. vue template 6. haml 7. View examples for a different event 8. Exit TEXT end context 'for an event with identifiers' do let(:expected_rails_example) do <<~TEXT.chomp -------------------------------------------------- # RAILS Gitlab::InternalEvents.track_event( 'internal_events_cli_used', project: project, namespace: project.namespace, user: user ) -------------------------------------------------- TEXT end let(:expected_rspec_example) do <<~TEXT.chomp -------------------------------------------------- # RSPEC it_behaves_like 'internal event tracking' do let(:event) { 'internal_events_cli_used' } let(:project) { project } let(:namespace) { project.namespace } let(:user) { user } end -------------------------------------------------- TEXT end before do File.write(event1_filepath, File.read(event1_content)) end it 'shows backend examples' do queue_cli_inputs([ "3\n", # Enum-select: View Usage -- look at code examples for an existing event 'internal_events_cli_used', # Filters to this event "\n", # Select: config/events/internal_events_cli_used.yml "\n", # Select: ruby/rails "\e[B", # Arrow down to: rspec "\n", # Select: rspec "8\n" # Exit ]) run_with_timeout output = plain_last_lines(100) expect(output).to include expected_example_prompt expect(output).to include expected_rails_example expect(output).to include expected_rspec_example end end context 'for an event without identifiers' do let(:expected_rails_example) do <<~TEXT.chomp -------------------------------------------------- # RAILS Gitlab::InternalEvents.track_event('internal_events_cli_opened') -------------------------------------------------- TEXT end let(:expected_rspec_example) do <<~TEXT.chomp -------------------------------------------------- # RSPEC it_behaves_like 'internal event tracking' do let(:event) { 'internal_events_cli_opened' } end -------------------------------------------------- TEXT end let(:expected_vue_example) do <<~TEXT.chomp -------------------------------------------------- // VUE -------------------------------------------------- TEXT end let(:expected_js_example) do <<~TEXT.chomp -------------------------------------------------- // FRONTEND -- RAW JAVASCRIPT import { InternalEvents } from '~/tracking'; export const performAction = () => { InternalEvents.trackEvent('internal_events_cli_opened'); return true; }; -------------------------------------------------- TEXT end let(:expected_vue_template_example) do <<~TEXT.chomp -------------------------------------------------- // VUE TEMPLATE -- ON-CLICK -------------------------------------------------- // VUE TEMPLATE -- ON-LOAD -------------------------------------------------- TEXT end let(:expected_haml_example) do <<~TEXT.chomp -------------------------------------------------- # HAML -- ON-CLICK .gl-display-inline-block{ data: { event_tracking: 'internal_events_cli_opened' } } = _('Important Text') -------------------------------------------------- # HAML -- COMPONENT ON-CLICK = render Pajamas::ButtonComponent.new(button_options: { data: { event_tracking: 'internal_events_cli_opened' } }) -------------------------------------------------- # HAML -- COMPONENT ON-LOAD = render Pajamas::ButtonComponent.new(button_options: { data: { event_tracking_load: true, event_tracking: 'internal_events_cli_opened' } }) -------------------------------------------------- TEXT end before do File.write(event2_filepath, File.read(event2_content)) end it 'shows all examples' do queue_cli_inputs([ "3\n", # Enum-select: View Usage -- look at code examples for an existing event 'internal_events_cli_opened', # Filters to this event "\n", # Select: config/events/internal_events_cli_used.yml "\n", # Select: ruby/rails "\e[B", # Arrow down to: rspec "\n", # Select: rspec "\e[B", # Arrow down to: js vue "\n", # Select: js vue "\e[B", # Arrow down to: js plain "\n", # Select: js plain "\e[B", # Arrow down to: vue template "\n", # Select: vue template "\e[B", # Arrow down to: haml "\n", # Select: haml "8\n" # Exit ]) run_with_timeout output = plain_last_lines(1000) expect(output).to include expected_example_prompt expect(output).to include expected_rails_example expect(output).to include expected_rspec_example expect(output).to include expected_vue_example expect(output).to include expected_js_example expect(output).to include expected_vue_template_example expect(output).to include expected_haml_example end end context 'when viewing examples for multiple events' do let(:expected_event1_example) do <<~TEXT.chomp -------------------------------------------------- # RAILS Gitlab::InternalEvents.track_event( 'internal_events_cli_used', project: project, namespace: project.namespace, user: user ) -------------------------------------------------- TEXT end let(:expected_event2_example) do <<~TEXT.chomp -------------------------------------------------- # RAILS Gitlab::InternalEvents.track_event('internal_events_cli_opened') -------------------------------------------------- TEXT end before do File.write(event1_filepath, File.read(event1_content)) File.write(event2_filepath, File.read(event2_content)) end it 'switches between events gracefully' do queue_cli_inputs([ "3\n", # Enum-select: View Usage -- look at code examples for an existing event 'internal_events_cli_used', # Filters to this event "\n", # Select: config/events/internal_events_cli_used.yml "\n", # Select: ruby/rails "7\n", # Select: View examples for a different event 'internal_events_cli_opened', # Filters to this event "\n", # Select: config/events/internal_events_cli_opened.yml "\n", # Select: ruby/rails "8\n" # Exit ]) run_with_timeout output = plain_last_lines(300) expect(output).to include expected_example_prompt expect(output).to include expected_event1_example expect(output).to include expected_event2_example end end end context 'when offline' do before do stub_product_groups(nil) end it_behaves_like 'creates the right defintion files', 'Creates a new event with product stage/section/group input manually' do let(:keystrokes) do [ "1\n", # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances "Internal Event CLI is opened\n", # Submit description "internal_events_cli_opened\n", # Submit action name "6\n", # Select: None "\n", # Skip MR URL "analytics\n", # Input section "monitor\n", # Input stage "analytics_instrumentation\n", # Input group "2\n", # Select [premium, ultimate] "y\n", # Create file "3\n" # Exit ] end let(:output_files) { [{ 'path' => event2_filepath, 'content' => event2_content }] } end it_behaves_like 'creates the right defintion files', 'Creates a new metric with product stage/section/group input manually' do let(:keystrokes) do [ "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions 'internal_events_cli', # Filters to the relevant events ' ', # Multi-select: internal_events_cli_closed "\e[B", # Arrow down to: internal_events_cli_used ' ', # Multi-select: internal_events_cli_used "\n", # Submit selections "\e[B", # Arrow down to: Weekly count of unique projects "\n", # Select: Weekly count of unique projects "where a defition file was created with the CLI\n", # Input description "\n", # Submit weekly description for monthly "2\n", # Select: Modify attributes "\n", # Accept section "\n", # Accept stage "\n", # Accept group "\n", # Skip URL "1\n", # Select: [free, premium, ultimate] "y\n", # Create file "y\n", # Create file "2\n" # Exit ] end let(:input_files) do [ { 'path' => event1_filepath, 'content' => event1_content }, { 'path' => event3_filepath, 'content' => event3_content } ] end let(:output_files) do # rubocop:disable Layout/LineLength -- Long filepaths read better unbroken [{ 'path' => 'config/metrics/counts_28d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_monthly.yml', 'content' => 'spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml' }, { 'path' => 'config/metrics/counts_7d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_weekly.yml', 'content' => 'spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml' }] # rubocop:enable Layout/LineLength end end end context 'when window size is unavailable' do before do # `tput ` returns empty string on error stub_helper(:fetch_window_size, '') stub_helper(:fetch_window_height, '') end it_behaves_like 'creates the right defintion files', 'Terminal size does not prevent file creation' do let(:keystrokes) do [ "1\n", # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances "Internal Event CLI is opened\n", # Submit description "internal_events_cli_opened\n", # Submit action name "6\n", # Select: None "\n", # Skip MR URL "instrumentation\n", # Filter & select group "2\n", # Select [premium, ultimate] "y\n", # Create file "3\n" # Exit ] end let(:output_files) { [{ 'path' => event2_filepath, 'content' => event2_content }] } end end context "when user doesn't know what they're trying to do" do it "handles when user isn't trying to track product usage" do queue_cli_inputs([ "4\n", # Enum-select: ...am I in the right place? "n\n" # No --> Are you trying to track customer usage of a GitLab feature? ]) run_with_timeout expect(plain_last_lines(50)).to include("Oh no! This probably isn't the tool you need!") end it "handles when product usage can't be tracked with events" do queue_cli_inputs([ "4\n", # Enum-select: ...am I in the right place? "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? "n\n" # No --> Can usage for the feature be measured by tracking a specific user action? ]) run_with_timeout expect(plain_last_lines(50)).to include("Oh no! This probably isn't the tool you need!") end it 'handles when user needs to add a new event' do queue_cli_inputs([ "4\n", # Enum-select: ...am I in the right place? "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? "y\n", # Yes --> Can usage for the feature be measured by tracking a specific user action? "n\n", # No --> Is the event already tracked? "n\n" # No --> Ready to start? ]) run_with_timeout expect(plain_last_lines(30)).to include("Okay! The next step is adding a new event! (~5 min)") end it 'handles when user needs to add a new metric' do queue_cli_inputs([ "4\n", # Enum-select: ...am I in the right place? "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? "y\n", # Yes --> Can usage for the feature be measured by tracking a specific user action? "y\n", # Yes --> Is the event already tracked? "n\n" # No --> Ready to start? ]) run_with_timeout expect(plain_last_lines(30)).to include("Amazing! The next step is adding a new metric! (~8 min)") end end private def queue_cli_inputs(keystrokes) prompt.input << keystrokes.join('') prompt.input.rewind end def run_with_timeout(duration = 1) Timeout.timeout(duration) { described_class.new(prompt).run } rescue Timeout::Error # Timeout is needed to break out of the CLI, but we may want # to make assertions afterwards end def run_with_verbose_timeout(duration = 1) Timeout.timeout(duration) { described_class.new(prompt).run } rescue Timeout::Error => e # Re-raise error so CLI output is printed with the error message = <<~TEXT Awaiting input too long. Entire CLI output: #{ prompt.output.string.lines .map { |line| "\e[0;37m#{line}\e[0m" } # wrap in white .join('') .gsub("\e[1G", "\e[1G ") # align to error indent } TEXT raise e.class, message, e.backtrace end def plain_last_lines(size) prompt.output.string .lines .last(size) .join('') .gsub(/\e[^\sm]{2,4}[mh]/, '') end def collect_file_writes(collector) allow(File).to receive(:write).and_wrap_original do |original_method, *args, &block| filepath = args.first collector << filepath dirname = Pathname.new(filepath).dirname unless dirname.directory? FileUtils.mkdir_p dirname collector << dirname.to_s end original_method.call(*args, &block) end end def stub_milestone(milestone) stub_const("InternalEventsCli::Helpers::MILESTONE", milestone) end def stub_product_groups(body) allow(Net::HTTP).to receive(:get) .with(URI('https://gitlab.com/gitlab-com/www-gitlab-com/-/raw/master/data/stages.yml')) .and_return(body) end def stub_helper(helper, value) # rubocop:disable RSpec/AnyInstanceOf -- 'Next' helper not included in fast_spec_helper & next is insufficient allow_any_instance_of(InternalEventsCli::Helpers).to receive(helper).and_return(value) # rubocop:enable RSpec/AnyInstanceOf end def delete_files(files) files.each do |filepath| FileUtils.rm_f(Rails.root.join(filepath)) end end def internal_event_fixture(filepath) Rails.root.join('spec', 'fixtures', 'scripts', 'internal_events', filepath) end end