diff --git a/AGENTS.md b/AGENTS.md index 2dc88db..dfd7cf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,30 @@ Instead, add or update an `inv` task and then have CI or docs call that task. If a new backend capability introduces a meaningful setup, verification, fixture, or scenario workflow, add or extend a task for it as part of the same change. +## Local-Only Helper Code + +If helper code exists only for local development, local profiling, or test/E2E support, +prefer placing it in a path that the production Docker image does not copy. + +Current production image copies: + +- `app` +- `bin` +- `config` +- `db` +- `public` +- `script` +- `config.ru` +- `Rakefile` + +It does not copy `lib`, `spec`, `e2e`, or `tasks.py`. + +Practical rule: + +- production runtime code belongs in copied paths such as `app/` +- local-only helpers should prefer `lib/`, `spec/`, `e2e/`, or task-layer code when feasible +- if production must ignore a local-only feature, cover that with blackbox E2E against the production image + ## HTTP E2E The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state. diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index cf48ba7..ad55e1d 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -1,7 +1,18 @@ # frozen_string_literal: true class TournamentsController < ApplicationController - before_action :set_tournament, only: %i[show update destroy set_timer_end timer_end sync_state] + class NoOpRequestProfiling + def measure(_name) + yield + end + + def apply_to(_response, label:) + label + end + end + + before_action :set_tournament_for_show, only: %i[show] + before_action :set_tournament, only: %i[update destroy set_timer_end timer_end sync_state] before_action :authenticate_user!, only: %i[create update destroy set_timer_end] before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end] before_action :validate_create_params, only: %i[create] @@ -34,7 +45,11 @@ class TournamentsController < ApplicationController if show_params.fetch(:simple, 'false') == 'true' render json: @tournament, serializer: SimpleTournamentSerializer else - render json: @tournament, include: '**' + rendered_json = @request_profiling.measure('serialize_tournament') do + ActiveModelSerializers::SerializableResource.new(@tournament, include: '**').as_json + end + @request_profiling.apply_to(response, label: 'tournament.show') + render json: rendered_json end end @@ -161,12 +176,41 @@ class TournamentsController < ApplicationController @tournament = Tournament.find(params[:id]) end + def set_tournament_for_show + profiling = build_request_profiling + @tournament = profiling.measure('load_tournament') do + Tournament.includes( + :user, + :teams, + stages: [ + { matches: { match_scores: :team } }, + { groups: [ + { matches: { match_scores: :team } }, + { group_scores: :team } + ] } + ] + ).find(params[:id]) + end + @request_profiling = profiling + end + def index_params params.permit(:type) end def show_params - params.permit(:simple) + params.permit(:simple, :profile) + end + + def profiling_requested? + !Rails.env.production? && show_params.fetch(:profile, 'false') == 'true' + end + + def build_request_profiling + return NoOpRequestProfiling.new unless profiling_requested? + + require Rails.root.join('lib/local/request_profiling') + Local::RequestProfiling.new(enabled: true) end def tournament_params diff --git a/app/models/group_score.rb b/app/models/group_score.rb index 325efa5..0d71e46 100644 --- a/app/models/group_score.rb +++ b/app/models/group_score.rb @@ -114,10 +114,28 @@ class GroupScore < ApplicationRecord end def comparison_match_against(other, decider:) - group.matches.reload - .select do |match| - match.decider? == decider && match.teams.include?(team) && match.teams.include?(other.team) - end - .max_by(&:position) + comparison_matches_index[[decider, *[team.id, other.team.id].sort]] + end + + def comparison_matches_index + cache = group.instance_variable_get(:@comparison_matches_index) + return cache unless cache.nil? + + matches = if group.association(:matches).loaded? && group.matches.any? + group.matches + else + group.matches.includes(match_scores: :team).reload + end + + cache = matches.each_with_object({}) do |match, index| + team_ids = match.teams.map(&:id).sort + next unless team_ids.size == 2 + + key = [match.decider?, *team_ids] + existing_match = index[key] + index[key] = match if existing_match.nil? || match.position.to_i > existing_match.position.to_i + end + + group.instance_variable_set(:@comparison_matches_index, cache) end end diff --git a/app/models/match.rb b/app/models/match.rb index 779c1b1..21d0a6f 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -45,14 +45,18 @@ class Match < ApplicationRecord def scored_points_of(team) return 0 if decider? + return 0 unless (finished? || in_progress?) && teams.include?(team) - teams.include?(team) ? match_scores.find_by(team: team).points : 0 + match_score = match_score_for(team) + match_score ? match_score.points : 0 end def received_points_of(team) return 0 if decider? + return 0 unless (finished? || in_progress?) && teams.include?(team) - teams.include?(team) ? match_scores.find { |ms| ms.team != team }.points : 0 + opponent_match_score = opponent_match_score_for(team) + opponent_match_score ? opponent_match_score.points : 0 end def group_points_of(team) @@ -70,11 +74,20 @@ class Match < ApplicationRecord end def hidden_points_of(team) - teams.include?(team) ? match_scores.find_by(team: team).hidden_points : 0 + match_score = match_score_for(team) + match_score ? match_score.hidden_points : 0 end private + def match_score_for(team) + match_scores.find { |match_score| match_score.team_id == team.id } + end + + def opponent_match_score_for(team) + match_scores.find { |match_score| match_score.team_id != team.id } + end + def stage_xor_group errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present? end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 74c3f80..164023f 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -32,7 +32,7 @@ class Tournament < ApplicationRecord def group_stage # get the stage with level -1 (group stage) - stages.find_by(level: -1) + stages.find { |stage| stage.level == -1 } end def sync_push_enabled? diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb index 735fd4d..7480bbc 100644 --- a/app/services/group_stage_service.rb +++ b/app/services/group_stage_service.rb @@ -43,7 +43,7 @@ class GroupStageService # # This problem is only fixed for a group size of 4, because we did not come up with a generalized version of this # switcharoo magic and we needed it for groups of 4. - return unless team_size == 4 + return matches unless team_size == 4 matches[5].position, matches[1].position = matches[1].position, matches[5].position matches @@ -97,37 +97,15 @@ class GroupStageService end def ranking_decision_for(group_score) - sorted_group_scores = group_score.group.group_scores.reload.sort - index = sorted_group_scores.index(group_score) - return nil if index.nil? || sorted_group_scores.size <= 1 - - comparison_index = comparison_partner_index(sorted_group_scores, index) - return nil if comparison_index.nil? - - compared_group_score = sorted_group_scores[comparison_index] - comparison_reason = group_score.comparison_reason_against(compared_group_score) - - { - resolved_by: comparison_reason[:resolved_by].to_s, - compared_with_team_id: compared_group_score.team.id, - compared_with_team_name: compared_group_score.team.name, - direct_comparison_result: comparison_reason[:direct_comparison_result].to_s, - direct_comparison_match_id: comparison_reason[:direct_comparison_match_id], - hidden_points_result: comparison_reason[:hidden_points_result].to_s, - decider_match_id: comparison_reason[:decider_match_id], - tied: comparison_reason[:tied], - needs_decider_match: comparison_reason[:tied] && - unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots_for_group(group_score.group)) - } + ranking_decisions_for_group(group_score.group)[group_score_cache_key(group_score)] end def blocking_ties_for(group_stage) return [] if group_stage.nil? group_stage.groups.flat_map do |group| - sorted_scores = group.group_scores.reload.sort - sorted_scores.filter_map do |group_score| - decision = ranking_decision_for(group_score) + ranking_decisions_for_group(group).filter_map do |group_score_id, decision| + group_score = group_scores_for(group).find { |score| group_score_cache_key(score) == group_score_id } next unless decision&.fetch(:needs_decider_match, false) comparison_team_id = decision[:compared_with_team_id] @@ -286,5 +264,68 @@ class GroupStageService base_slots + (group_index < extra_slot_groups ? 1 : 0) end end + + def ranking_decisions_for_group(group) + cache = group.instance_variable_get(:@ranking_decisions_cache) + return cache unless cache.nil? + + sorted_group_scores = group_scores_for(group).sort + advancing_slots = advancing_slots_for_group(group) + group_ready_for_decider_check = regular_group_matches_finished?(group) + + cache = sorted_group_scores.each_with_index.each_with_object({}) do |(group_score, index), decisions| + next if sorted_group_scores.size <= 1 + + comparison_index = comparison_partner_index(sorted_group_scores, index) + next if comparison_index.nil? + + compared_group_score = sorted_group_scores[comparison_index] + comparison_reason = group_score.comparison_reason_against(compared_group_score) + + if comparison_reason[:tied] && !group_ready_for_decider_check + decisions[group_score_cache_key(group_score)] = nil + next + end + + decisions[group_score_cache_key(group_score)] = { + resolved_by: comparison_reason[:resolved_by].to_s, + compared_with_team_id: compared_group_score.team.id, + compared_with_team_name: compared_group_score.team.name, + direct_comparison_result: comparison_reason[:direct_comparison_result].to_s, + direct_comparison_match_id: comparison_reason[:direct_comparison_match_id], + hidden_points_result: comparison_reason[:hidden_points_result].to_s, + decider_match_id: comparison_reason[:decider_match_id], + tied: comparison_reason[:tied], + needs_decider_match: group_ready_for_decider_check && + comparison_reason[:tied] && + unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots) + } + end + + group.instance_variable_set(:@ranking_decisions_cache, cache) + end + + def group_scores_for(group) + if group.association(:group_scores).loaded? && group.group_scores.any? + group.group_scores + else + group.group_scores.includes(:team).reload + end + end + + def group_score_cache_key(group_score) + group_score.id || group_score.object_id + end + + def regular_group_matches_finished?(group) + matches = if group.association(:matches).loaded? && group.matches.any? + group.matches + else + group.matches.reload + end + + regular_matches = matches.reject(&:decider?) + regular_matches.any? && regular_matches.all?(&:finished?) + end end end diff --git a/docker-compose.blackbox.yml b/docker-compose.blackbox.yml index acaa2f4..fae52d2 100644 --- a/docker-compose.blackbox.yml +++ b/docker-compose.blackbox.yml @@ -38,9 +38,13 @@ services: working_dir: /app environment: TURNIERE_E2E_BASE_URL: ${TURNIERE_E2E_BASE_URL:-http://app:3000} + TURNIERE_E2E_EXPECT_PROFILING: ${TURNIERE_E2E_EXPECT_PROFILING:-true} TURNIERE_E2E_EMAIL: ${TURNIERE_E2E_EMAIL:-e2e@example.com} TURNIERE_E2E_PASSWORD: ${TURNIERE_E2E_PASSWORD:-password123} TURNIERE_E2E_USERNAME: ${TURNIERE_E2E_USERNAME:-e2e-user} + TURNIERE_E2E_ALT_EMAIL: ${TURNIERE_E2E_ALT_EMAIL:-e2e-alt@example.com} + TURNIERE_E2E_ALT_PASSWORD: ${TURNIERE_E2E_ALT_PASSWORD:-password123} + TURNIERE_E2E_ALT_USERNAME: ${TURNIERE_E2E_ALT_USERNAME:-e2e-alt-user} volumes: turniere-blackbox-postgres: diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile index 63bd222..a2446ba 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -6,6 +6,7 @@ RUN apt-get update -qq && apt-get install -y python3 python3-pip && apt-get clea RUN bundle config set with 'development test' && bundle install COPY tasks.py /app/tasks.py COPY e2e /app/e2e +COPY lib /app/lib COPY spec /app/spec COPY .rspec /app/.rspec ENV RAILS_ENV test diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index d11a3d8..5a2e6b5 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -3,6 +3,7 @@ require_relative 'api_client' require 'securerandom' require 'time' +require 'uri' module TurniereE2E class ScenarioRunner @@ -146,6 +147,27 @@ module TurniereE2E result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end + def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload( + group_count: group_count, + teams_per_group: teams_per_group, + playoff_teams_amount: playoff_teams_amount + ) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created + + profiled_show = fetch_tournament_profile!(tournament.fetch(:id)) + checkpoints[:profiled_show] = profiled_show + + result(session: session, tournament: profiled_show.fetch(:tournament), checkpoints: checkpoints, stop_at: stop_at) + end + def wait_for_healthcheck!(timeout_seconds: 30) started_at = Time.now loop do @@ -190,17 +212,23 @@ module TurniereE2E end def fetch_tournament!(tournament_id) - response = client.get("/tournaments/#{tournament_id}") + response = get_response!("/tournaments/#{tournament_id}") expect_status!(response, 200, "fetch tournament #{tournament_id}") response.fetch(:json) end def get!(path, params: nil) - response = client.get(path, params: params) + response = get_response!(path, params: params) expect_status!(response, 200, "get #{path}") response.fetch(:json) end + def get_response!(path, params: nil) + response = client.get(path, params: params) + expect_status!(response, 200, "get #{path}") + response + end + def patch!(path, body:) response = client.patch(path, body: body) expect_status!(response, 200, "patch #{path}") @@ -471,6 +499,31 @@ module TurniereE2E } end + def fetch_tournament_profile!(tournament_id) + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' }) + duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0 + + { + tournament: response.fetch(:json), + request_duration_ms: duration_ms.round(1), + server_timing: response.fetch(:headers).fetch('server-timing', ''), + profile_summary: response.fetch(:headers).fetch('x-turniere-profile', ''), + sections: parse_server_timing(response.fetch(:headers).fetch('server-timing', '')) + } + end + + def parse_server_timing(header) + return {} if header.nil? || header.empty? + + header.split(',').each_with_object({}) do |entry, sections| + name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures + next if name.nil? || duration.nil? + + sections[name] = duration.to_f + end + end + def result(session:, tournament:, checkpoints:, stop_at:) { session: session, diff --git a/lib/local/README.md b/lib/local/README.md new file mode 100644 index 0000000..240bcd2 --- /dev/null +++ b/lib/local/README.md @@ -0,0 +1,43 @@ +# Local-Only Backend Helpers + +This folder is for backend code that is useful in local development, local profiling, +test support, or E2E support, but should not be treated as normal production runtime code. + +## Why this folder exists + +The production Docker image currently copies these paths: + +- `app` +- `bin` +- `config` +- `db` +- `public` +- `script` +- `config.ru` +- `Rakefile` + +It does not copy `lib`, `spec`, `e2e`, or `tasks.py`. + +That means code placed in `lib/local/` may not end up in the production image. + +## What belongs here + +- request profiling helpers used only in local or test flows +- support code for local verification +- helper code used by local-only workflows that should stay outside the production runtime + +## What should not go here + +- normal application runtime code needed in production +- code that controllers, models, jobs, or services must always have available in production + +## Related special folders + +Depending on purpose, local-only or non-production support code may also belong in: + +- `spec/` for test-only code +- `e2e/` for reusable HTTP E2E helpers and scenarios +- task-layer code such as `tasks.py` for local/CI workflows + +If code in this folder is referenced from production code paths, those call sites must guard +against production usage and fail safe when the helper is absent. diff --git a/lib/local/request_profiling.rb b/lib/local/request_profiling.rb new file mode 100644 index 0000000..d231eea --- /dev/null +++ b/lib/local/request_profiling.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Local + class RequestProfiling + IGNORED_SQL_NAMES = %w[SCHEMA CACHE].freeze + IGNORED_SQL = /\A(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i + + def initialize(enabled: false) + @enabled = enabled + @measurements = [] + end + + def measure(name) + return yield unless @enabled + + query_count = 0 + subscriber = lambda do |_event_name, _start, _finish, _id, payload| + sql = payload[:sql].to_s + next if IGNORED_SQL_NAMES.include?(payload[:name]) + next if sql.match?(IGNORED_SQL) + + query_count += 1 + end + + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = nil + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + result = yield + end + duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0 + @measurements << { name: name, duration: duration.round(1), query_count: query_count } + result + end + + def apply_to(response, label:) + return unless @enabled + + response.set_header('Server-Timing', @measurements.map { |m| "#{m[:name]};dur=#{m[:duration]}" }.join(', ')) + response.set_header( + 'X-Turniere-Profile', + "#{label}: #{@measurements.map { |m| "#{m[:name]}=#{m[:duration]}ms/#{m[:query_count]}sql" }.join(', ')}" + ) + end + end +end diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb index a5ce189..fad48d8 100644 --- a/script/e2e_scenarios.rb +++ b/script/e2e_scenarios.rb @@ -9,6 +9,9 @@ options = { base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'), stop_at: nil, team_count: 8, + group_count: 4, + teams_per_group: 4, + playoff_teams_amount: 8, email: ENV['TURNIERE_E2E_EMAIL'], password: ENV['TURNIERE_E2E_PASSWORD'], username: ENV['TURNIERE_E2E_USERNAME'] @@ -20,6 +23,11 @@ OptionParser.new do |parser| parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value } parser.on('--stop-at CHECKPOINT', 'Stop scenario at a named checkpoint') { |value| options[:stop_at] = value.to_sym } parser.on('--team-count COUNT', Integer, 'Team count for playoff_only scenario') { |value| options[:team_count] = value } + parser.on('--group-count COUNT', Integer, 'Group count for group-stage scenarios') { |value| options[:group_count] = value } + parser.on('--teams-per-group COUNT', Integer, 'Teams per group for group-stage scenarios') { |value| options[:teams_per_group] = value } + parser.on('--playoff-teams-amount COUNT', Integer, 'Playoff teams amount for group-stage scenarios') do |value| + options[:playoff_teams_amount] = value + end parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value } parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value } parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value } @@ -40,6 +48,13 @@ result = case scenario runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) when 'playoff_only' runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) + when 'group_stage_render_profile' + runner.run_group_stage_render_profile( + group_count: options[:group_count], + teams_per_group: options[:teams_per_group], + playoff_teams_amount: options[:playoff_teams_amount], + stop_at: options[:stop_at] || :profiled_show + ) else abort("Unknown scenario #{scenario.inspect}") end diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index 7e768a0..355cd6e 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -3,6 +3,35 @@ require 'rails_helper' RSpec.describe StagesController, type: :controller do + def create_finished_resolved_group_stage(group_count: 4, teams_per_group: 2) + tournament = create( + :tournament, + teams_count: group_count * teams_per_group, + playoff_teams_amount: group_count, + instant_finalists_amount: group_count, + intermediate_round_participants_amount: 0 + ) + groups = tournament.teams.each_slice(teams_per_group).to_a + stage = GroupStageService.generate_group_stage(groups) + tournament.stages << stage + tournament.save! + + stage.groups.each do |group| + group.matches.each do |match| + match.state = :finished + match.match_scores.each_with_index do |match_score, index| + match_score.points = index.zero? ? 1 : 0 + match_score.save! + end + match.save! + end + + UpdateGroupsGroupScoresAndSave.call(group: group) + end + + stage.reload + end + let(:stage) do create(:playoff_stage) end @@ -29,7 +58,7 @@ RSpec.describe StagesController, type: :controller do context 'group_stage with matches that are done' do let(:running_group_stage) do - create(:group_stage, match_factory: :finished_group_match) + create_finished_resolved_group_stage end it 'doesn\'t have any other stages besides it before update' do @@ -98,7 +127,7 @@ RSpec.describe StagesController, type: :controller do context 'already finished group stage' do let(:finished_group_stage) do - group_stage = create(:group_stage, match_factory: :finished_group_match) + group_stage = create_finished_resolved_group_stage group_stage.finished! group_stage.save! group_stage diff --git a/spec/controllers/statistics_controller_spec.rb b/spec/controllers/statistics_controller_spec.rb index bd105e1..b0af6ea 100644 --- a/spec/controllers/statistics_controller_spec.rb +++ b/spec/controllers/statistics_controller_spec.rb @@ -35,7 +35,8 @@ RSpec.describe StatisticsController, type: :controller do it 'returns a list containing all group scores' do get :index, params: { tournament_id: @tournament.to_param } - expect(deserialize_response(response)[:group_scores].length).to eq(GroupScore.count) + expected_count = @group_stage.groups.sum { |group| group.group_scores.size } + expect(deserialize_response(response)[:group_scores].length).to eq(expected_count) end it 'returns a most dominant group score' do diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index a0447e4..504d9a3 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -118,6 +118,26 @@ RSpec.describe TournamentsController, type: :controller do expect(body[:teams]).to be_nil end end + + context 'with profile=true parameter' do + it 'adds profiling headers outside production' do + get :show, params: { id: @tournament.to_param, profile: 'true' } + + expect(response.headers['Server-Timing']).to include('load_tournament') + expect(response.headers['Server-Timing']).to include('serialize_tournament') + expect(response.headers['X-Turniere-Profile']).to include('tournament.show') + end + + it 'ignores profiling in production' do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) + + get :show, params: { id: @tournament.to_param, profile: 'true' } + + expect(response.headers['Server-Timing']).to be_nil + expect(response.headers['X-Turniere-Profile']).to be_nil + end + end + context 'on a group stage tournament' do before do @group_stage_tournament = create(:group_stage_tournament) diff --git a/spec/e2e/http/tournament_rendering_spec.rb b/spec/e2e/http/tournament_rendering_spec.rb new file mode 100644 index 0000000..cf89137 --- /dev/null +++ b/spec/e2e/http/tournament_rendering_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../e2e/lib/scenario_runner' + +RSpec.describe 'Tournament rendering HTTP E2E' do + required_env_vars = %w[TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD].freeze + + before do + missing_vars = required_env_vars.reject { |name| ENV.key?(name) } + skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty? + end + + let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') } + let(:expect_profiling) { ENV.fetch('TURNIERE_E2E_EXPECT_PROFILING', 'true') == 'true' } + let(:runner) do + TurniereE2E::ScenarioRunner.new( + base_url: base_url, + email: ENV.fetch('TURNIERE_E2E_EMAIL'), + password: ENV.fetch('TURNIERE_E2E_PASSWORD'), + username: ENV['TURNIERE_E2E_USERNAME'] + ) + end + + [ + { group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, max_server_duration_ms: 1000 }, + { group_count: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000, max_server_duration_ms: 5000 }, + { group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000, max_server_duration_ms: 5000 } + ].each do |scenario| + it "creates #{scenario[:group_count]}x#{scenario[:teams_per_group]} via API and renders within budget" do + result = runner.run_group_stage_render_profile( + group_count: scenario[:group_count], + teams_per_group: scenario[:teams_per_group], + playoff_teams_amount: scenario[:playoff_teams_amount] + ) + + profiled_show = result.dig(:checkpoints, :profiled_show) + sections = profiled_show.fetch(:sections) + total_server_duration_ms = sections.values.sum + tournament = profiled_show.fetch(:tournament) + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + + expect(group_stage.fetch(:groups).size).to eq(scenario[:group_count]) + expect(tournament.fetch(:teams).size).to eq(scenario[:group_count] * scenario[:teams_per_group]) + if expect_profiling + expect(profiled_show.fetch(:server_timing)).to include('load_tournament') + expect(profiled_show.fetch(:server_timing)).to include('serialize_tournament') + expect(profiled_show.fetch(:profile_summary)).to include('sql') + expect(sections.fetch('load_tournament')).to be <= scenario[:max_server_duration_ms] + expect(sections.fetch('serialize_tournament')).to be <= scenario[:max_server_duration_ms] + expect(total_server_duration_ms).to be <= scenario[:max_server_duration_ms] + expect(profiled_show.fetch(:request_duration_ms)).to be <= scenario[:max_request_duration_ms] + else + expect(profiled_show.fetch(:server_timing)).to eq('') + expect(profiled_show.fetch(:profile_summary)).to eq('') + expect(sections).to eq({}) + end + end + end +end diff --git a/spec/e2e_known_behaviors.md b/spec/e2e_known_behaviors.md index d6154ca..20243b9 100644 --- a/spec/e2e_known_behaviors.md +++ b/spec/e2e_known_behaviors.md @@ -32,3 +32,13 @@ Observed and covered by `spec/e2e/http/tournament_lifecycle_spec.rb` and `script - `1` regular match This can look surprising if `playoff_teams_amount = 4` is read as "exactly four teams leave the group stage". The current backend instead models "four playoff slots after the intermediate round", which is the behavior the new E2E tests lock in. + +## Large tournament render profiling + +Observed and covered by `spec/e2e/http/tournament_rendering_spec.rb` and `script/e2e_scenarios.rb`: + +- large group-stage tournaments are created through the same authenticated HTTP `POST /tournaments` flow as normal clients +- tournament show profiling is requested via `GET /tournaments/:id?profile=true` +- profiling is intentionally non-production-only; local/test flows may use it, production should ignore it +- local/test runs return `Server-Timing` and `X-Turniere-Profile` headers with `load_tournament` and `serialize_tournament` +- current locked shapes are `8x4`, `64x6`, and `4x32` diff --git a/spec/factories/matches.rb b/spec/factories/matches.rb index 9e10eb0..b05ca6e 100644 --- a/spec/factories/matches.rb +++ b/spec/factories/matches.rb @@ -63,6 +63,11 @@ FactoryBot.define do factory :finished_group_match do state { :finished } + after(:create) do |match| + match.match_scores.order(:id).each_with_index do |match_score, index| + match_score.update!(points: index.zero? ? 1 : 0) + end + end end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 196c94f..9667dc8 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -2,8 +2,8 @@ FactoryBot.define do factory :user do - username { Faker::Internet.unique.username } - email { Faker::Internet.unique.email } - password { Faker::Internet.password } + sequence(:username) { |n| "user#{n}" } + sequence(:email) { |n| "user#{n}@example.com" } + password { 'password123' } end end diff --git a/spec/models/group_score_spec.rb b/spec/models/group_score_spec.rb index 2e4e184..a2c61aa 100644 --- a/spec/models/group_score_spec.rb +++ b/spec/models/group_score_spec.rb @@ -78,9 +78,9 @@ RSpec.describe GroupScore, type: :model do it 'reports the direct comparison result when head-to-head resolves the tie' do group_score_b.update!(group_points: 6) - create(:group_match, group: group, state: :finished).tap do |match| - create(:match_score, match: match, team: team_a, points: 3) - create(:match_score, match: match, team: team_b, points: 1) + match = create(:group_match, group: group, state: :finished).tap do |created_match| + create(:match_score, match: created_match, team: team_a, points: 3) + create(:match_score, match: created_match, team: team_b, points: 1) end expect(group_score_a.comparison_reason_against(group_score_b)).to eq( @@ -88,16 +88,16 @@ RSpec.describe GroupScore, type: :model do tied: false, direct_comparison_result: :won, hidden_points_result: :not_played, - direct_comparison_match_id: group.matches.last.id, + direct_comparison_match_id: match.id, decider_match_id: nil ) end it 'reports an unresolved tie when head-to-head was a draw' do group_score_b.update!(group_points: 6) - create(:group_match, group: group, state: :finished).tap do |match| - create(:match_score, match: match, team: team_a, points: 2) - create(:match_score, match: match, team: team_b, points: 2) + match = create(:group_match, group: group, state: :finished).tap do |created_match| + create(:match_score, match: created_match, team: team_a, points: 2) + create(:match_score, match: created_match, team: team_b, points: 2) end expect(group_score_a.comparison_reason_against(group_score_b)).to eq( @@ -105,20 +105,20 @@ RSpec.describe GroupScore, type: :model do tied: true, direct_comparison_result: :draw, hidden_points_result: :not_played, - direct_comparison_match_id: group.matches.last.id, + direct_comparison_match_id: match.id, decider_match_id: nil ) end it 'falls back to hidden points when direct comparison is still tied' do group_score_b.update!(group_points: 6) - create(:group_match, group: group, state: :finished).tap do |match| - create(:match_score, match: match, team: team_a, points: 2) - create(:match_score, match: match, team: team_b, points: 2) + direct_match = create(:group_match, group: group, state: :finished).tap do |created_match| + create(:match_score, match: created_match, team: team_a, points: 2) + create(:match_score, match: created_match, team: team_b, points: 2) end - create(:group_match, group: group, state: :finished, decider: true).tap do |match| - create(:match_score, match: match, team: team_a, hidden_points: 3) - create(:match_score, match: match, team: team_b, hidden_points: 1) + decider_match = create(:group_match, group: group, state: :finished, decider: true).tap do |created_match| + create(:match_score, match: created_match, team: team_a, hidden_points: 3) + create(:match_score, match: created_match, team: team_b, hidden_points: 1) end expect(group_score_a.comparison_reason_against(group_score_b)).to eq( @@ -126,8 +126,8 @@ RSpec.describe GroupScore, type: :model do tied: false, direct_comparison_result: :draw, hidden_points_result: :won, - direct_comparison_match_id: group.matches.reject(&:decider?).last.id, - decider_match_id: group.matches.select(&:decider?).last.id + direct_comparison_match_id: direct_match.id, + decider_match_id: decider_match.id ) end end diff --git a/spec/models/match_spec.rb b/spec/models/match_spec.rb index 6610b96..d8c4226 100644 --- a/spec/models/match_spec.rb +++ b/spec/models/match_spec.rb @@ -109,21 +109,21 @@ RSpec.describe Match, type: :model do end it 'returns correct group_points' do - expect(@match.group_points_of(@team1)).to be(1) - expect(@match.group_points_of(@team2)).to be(1) - expect(@match.group_points_of(@uninvolved_team)).to be(0) + expect(@match.group_points_of(@team1)).to eq(1) + expect(@match.group_points_of(@team2)).to eq(1) + expect(@match.group_points_of(@uninvolved_team)).to eq(0) end it 'returns correct scored_points' do - expect(@match.scored_points_of(@team1)).to be(34) - expect(@match.scored_points_of(@team2)).to be(34) - expect(@match.scored_points_of(@uninvolved_team)).to be(0) + expect(@match.scored_points_of(@team1)).to eq(34) + expect(@match.scored_points_of(@team2)).to eq(34) + expect(@match.scored_points_of(@uninvolved_team)).to eq(0) end it 'returns correct received_points' do - expect(@match.received_points_of(@team1)).to be(34) - expect(@match.received_points_of(@team2)).to be(34) - expect(@match.received_points_of(@uninvolved_team)).to be(0) + expect(@match.received_points_of(@team1)).to eq(34) + expect(@match.received_points_of(@team2)).to eq(34) + expect(@match.received_points_of(@uninvolved_team)).to eq(0) end end @@ -134,15 +134,15 @@ RSpec.describe Match, type: :model do end it 'returns correct group_points' do - expect(@not_started_match.group_points_of(@team1)).to be(0) + expect(@not_started_match.group_points_of(@team1)).to eq(0) end it 'returns correct scored_points' do - expect(@match.scored_points_of(@team1)).to be(0) + expect(@not_started_match.scored_points_of(@team1)).to eq(0) end it 'returns correct received_points' do - expect(@match.received_points_of(@team1)).to be(0) + expect(@not_started_match.received_points_of(@team1)).to eq(0) end end @@ -155,21 +155,21 @@ RSpec.describe Match, type: :model do end it 'returns correct group_points' do - expect(@match.group_points_of(@team1)).to be(3) - expect(@match.group_points_of(@team2)).to be(0) - expect(@match.group_points_of(@uninvolved_team)).to be(0) + expect(@match.group_points_of(@team1)).to eq(3) + expect(@match.group_points_of(@team2)).to eq(0) + expect(@match.group_points_of(@uninvolved_team)).to eq(0) end it 'returns correct scored_points' do - expect(@match.scored_points_of(@team1)).to be(42) - expect(@match.scored_points_of(@team2)).to be(17) - expect(@match.scored_points_of(@uninvolved_team)).to be(0) + expect(@match.scored_points_of(@team1)).to eq(42) + expect(@match.scored_points_of(@team2)).to eq(17) + expect(@match.scored_points_of(@uninvolved_team)).to eq(0) end it 'returns correct received_points' do - expect(@match.received_points_of(@team1)).to be(17) - expect(@match.received_points_of(@team2)).to be(42) - expect(@match.received_points_of(@uninvolved_team)).to be(0) + expect(@match.received_points_of(@team1)).to eq(17) + expect(@match.received_points_of(@team2)).to eq(42) + expect(@match.received_points_of(@uninvolved_team)).to eq(0) end end end diff --git a/spec/requests/tournament_rendering_performance_spec.rb b/spec/requests/tournament_rendering_performance_spec.rb new file mode 100644 index 0000000..a38c27e --- /dev/null +++ b/spec/requests/tournament_rendering_performance_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' +require_relative '../support/performance_helpers' + +RSpec.describe 'Tournament rendering performance', type: :request do + [ + { groups_count: 8, teams_per_group: 4, max_duration_ms: 1000 }, + { groups_count: 64, teams_per_group: 6, max_duration_ms: 5000 }, + { groups_count: 4, teams_per_group: 32, max_duration_ms: 5000 } + ].each do |scenario| + it "renders #{scenario[:groups_count]} groups with #{scenario[:teams_per_group]} teams within budget" do + tournament = create_group_stage_tournament( + groups_count: scenario[:groups_count], + teams_per_group: scenario[:teams_per_group] + ) + + get "/tournaments/#{tournament.id}" + expect(response).to have_http_status(:ok) + + performance = capture_runtime_and_queries do + get "/tournaments/#{tournament.id}", params: { profile: 'true' } + end + + expect(response).to have_http_status(:ok) + expect(response.headers['Server-Timing']).to include('load_tournament') + expect(response.headers['Server-Timing']).to include('serialize_tournament') + expect(response.headers['X-Turniere-Profile']).to include('tournament.show') + expect(response.headers['X-Turniere-Profile']).to include('sql') + + body = response.parsed_body + expect(body.fetch('stages').first.fetch('groups').size).to eq(scenario[:groups_count]) + expect(body.fetch('teams').size).to eq(scenario[:groups_count] * scenario[:teams_per_group]) + expect(performance[:duration_ms]).to be <= scenario[:max_duration_ms] + end + end +end diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index 1cf60fb..6801da3 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -89,6 +89,14 @@ RSpec.describe GroupStageService do expect(match.teams.first).to_not eq(match.teams.second) end end + + it 'also generates matches for group sizes other than four' do + teams = create_list(:team, 6) + + matches = GroupStageService.generate_all_matches_between(teams) + + expect(matches.size).to eq(15) + end end describe '#update_group_scores' do @@ -102,7 +110,7 @@ RSpec.describe GroupStageService do context 'with only undecided matches' do before do - @group = create(:group, match_factory: :running_group_match) + @group = create(:group, match_count: 1, match_factory: :running_group_match) @group.matches.each do |match| match.match_scores.each do |ms| ms.points = 42 @@ -135,7 +143,7 @@ RSpec.describe GroupStageService do context 'with only decided matches' do before do - @group = create(:group, match_factory: :running_group_match) + @group = create(:group, match_count: 1, match_factory: :running_group_match) @group.matches.each_with_index do |match, i| match.match_scores.each_with_index do |ms, j| match_score_number = i + j @@ -292,7 +300,26 @@ RSpec.describe GroupStageService do ) end - it 'marks unresolved ties outside the advancing cutoff without requesting a decider' do + it 'does not expose unresolved tie metadata while regular matches in the group are still unfinished' do + tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0) + + create(:group_match, group: group, state: :in_progress).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) + end + + score_a = create(:group_score, group: group, team: team_a, group_points: 1, scored_points: 2, received_points: 2) + score_b = create(:group_score, group: group, team: team_b, group_points: 1, scored_points: 2, received_points: 2) + score_c = create(:group_score, group: group, team: team_c, group_points: 0, scored_points: 0, received_points: 0) + score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 0, received_points: 0) + + GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!) + + expect(GroupStageService.ranking_decision_for(score_a)).to be_nil + expect(GroupStageService.ranking_decision_for(score_b)).to be_nil + end + + it 'does not expose unresolved tie metadata outside the advancing cutoff before the group is finished' do tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0) score_a = create(:group_score, group: group, team: team_a, group_points: 9, scored_points: 5, received_points: 1, position: 1) @@ -303,18 +330,8 @@ RSpec.describe GroupStageService do GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!) expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 3]) - expect(GroupStageService.ranking_decision_for(score_c)).to include( - resolved_by: 'tie', - hidden_points_result: 'not_played', - tied: true, - needs_decider_match: false - ) - expect(GroupStageService.ranking_decision_for(score_d)).to include( - resolved_by: 'tie', - hidden_points_result: 'not_played', - tied: true, - needs_decider_match: false - ) + expect(GroupStageService.ranking_decision_for(score_c)).to be_nil + expect(GroupStageService.ranking_decision_for(score_d)).to be_nil end it 'uses the decider hidden points to resolve a cutoff tie' do diff --git a/spec/services/statistics_service_spec.rb b/spec/services/statistics_service_spec.rb index ec6b3fa..e3b251b 100644 --- a/spec/services/statistics_service_spec.rb +++ b/spec/services/statistics_service_spec.rb @@ -5,6 +5,7 @@ RSpec.describe StatisticsService do # build tournament with predictable test data tournament = create(:tournament) group_stage = create(:group_stage) + @group_stage = group_stage group = group_stage.groups.first @most_dominant_score = create(:group_score, group_points: 100, @@ -48,7 +49,7 @@ RSpec.describe StatisticsService do describe '#group_scores' do it 'returns an array containing all group scores' do - expect(@service.group_scores.length).to eq(GroupScore.count) + expect(@service.group_scores.length).to eq(@group_stage.groups.sum { |group| group.group_scores.size }) end end end diff --git a/spec/support/performance_helpers.rb b/spec/support/performance_helpers.rb new file mode 100644 index 0000000..44b22a3 --- /dev/null +++ b/spec/support/performance_helpers.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module PerformanceHelpers + IGNORED_SQL_NAMES = %w[SCHEMA CACHE].freeze + IGNORED_SQL = /\A(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i + + def capture_runtime_and_queries + query_count = 0 + subscriber = lambda do |_name, _start, _finish, _id, payload| + sql = payload[:sql].to_s + next if IGNORED_SQL_NAMES.include?(payload[:name]) + next if sql.match?(IGNORED_SQL) + + query_count += 1 + end + + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = nil + + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + result = yield + end + + duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0 + { result: result, query_count: query_count, duration_ms: duration_ms } + end + + def create_group_stage_tournament(groups_count:, teams_per_group:) + owner = create(:user) + tournament = owner.tournaments.create!( + name: "Perf #{groups_count}x#{teams_per_group}", + description: 'Tournament render performance fixture', + public: true, + playoff_teams_amount: groups_count + ) + + groups = Array.new(groups_count) do |group_index| + Array.new(teams_per_group) do |team_index| + tournament.teams.create!(name: "G#{group_index + 1}-T#{team_index + 1}") + end + end + + group_stage = GroupStageService.generate_group_stage(groups) + tournament.stages = [group_stage] + tournament.instant_finalists_amount, tournament.intermediate_round_participants_amount = + TournamentService.calculate_default_amount_of_teams_advancing(tournament.playoff_teams_amount, groups_count) + tournament.save! + + tournament.reload + end +end + +RSpec.configure do |config| + config.include PerformanceHelpers +end diff --git a/tasks.py b/tasks.py index 16a2dfc..206f382 100644 --- a/tasks.py +++ b/tasks.py @@ -139,7 +139,7 @@ def _run_subprocess(command, env=None, capture_output=False, check=True): return completed -def _run_subprocess_live(command, env=None, check=True): +def _stream_subprocess(command, env=None, check=True): process = subprocess.Popen( command, env=env, @@ -152,20 +152,16 @@ def _run_subprocess_live(command, env=None, check=True): output_chunks = [] assert process.stdout is not None for line in process.stdout: - output_chunks.append(line) print(line, end="", flush=True) + output_chunks.append(line) process.wait() - stdout = "".join(output_chunks) - completed = subprocess.CompletedProcess(command, process.returncode, stdout=stdout, stderr="") - if check and completed.returncode != 0: - raise subprocess.CalledProcessError( - completed.returncode, - command, - output=completed.stdout, - stderr=completed.stderr, - ) - return completed + output = "".join(output_chunks) + + if check and process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, command, output=output, stderr=None) + + return subprocess.CompletedProcess(command, process.returncode, stdout=output, stderr="") def _compose_env( @@ -173,6 +169,7 @@ def _compose_env( app_image=PRODUCTION_TAG, runner_image=TEST_TAG, postgres_image=BLACKBOX_POSTGRES_IMAGE, + expect_profiling="true", ): return _env( TURNIERE_BLACKBOX_HOST_PORT=host_port, @@ -186,6 +183,7 @@ def _compose_env( TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY, TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN, TURNIERE_E2E_BASE_URL=BLACKBOX_INTERNAL_BASE_URL, + TURNIERE_E2E_EXPECT_PROFILING=expect_profiling, TURNIERE_E2E_EMAIL=E2E_EMAIL, TURNIERE_E2E_PASSWORD=E2E_PASSWORD, TURNIERE_E2E_USERNAME=E2E_USERNAME, @@ -230,6 +228,15 @@ def _format_command(command): return " ".join(shlex.quote(part) for part in command) +def _rspec_command(*paths): + command = ["bundle", "exec", "rspec", *paths] + if os.environ.get("CI"): + command.extend(["--format", "documentation", "--no-color"]) + else: + command.extend(["--format", "progress", "--tty"]) + return command + + def _compose_run(compose_args, env, capture_output=False, check=True, project_name=BLACKBOX_PROJECT, compose_file=BLACKBOX_COMPOSE_FILE): command = _docker_compose_cmd(project_name=project_name, compose_file=compose_file) + compose_args return _run_subprocess(command, env=env, capture_output=capture_output, check=check) @@ -397,6 +404,7 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E alt_username=E2E_ALT_USERNAME, spec_path="spec/e2e/http", extra_env=None): env = _env( TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EXPECT_PROFILING="false", TURNIERE_E2E_EMAIL=email, TURNIERE_E2E_PASSWORD=password, TURNIERE_E2E_USERNAME=username, @@ -405,10 +413,10 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E TURNIERE_E2E_ALT_USERNAME=alt_username, ) if extra_env: - env.update(extra_env) - command = ["bundle", "exec", "rspec", spec_path, "--format", "documentation"] + env.update(extra_env) + command = _rspec_command(spec_path) print(_format_command(command), flush=True) - result = _run_subprocess_live(command, env=env, check=False) + result = _stream_subprocess(command, env=env, check=False) report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}") _print_header("Blackbox report") @@ -551,7 +559,7 @@ def start_test_local( @task def test(c): """Run the normal RSpec suite.""" - c.run(_bundle_exec("rspec"), pty=True) + _stream_subprocess(_rspec_command(), env=_env()) @task(name="test-shard") @@ -583,9 +591,9 @@ def test_shard(c, node_index=1, node_total=8, dry_run=False): if not files: return - c.run( - _bundle_exec("rspec") + " --require ./spec/rails_helper " + " ".join(shlex.quote(path) for path in files), - pty=True, + _stream_subprocess( + _rspec_command("--require", "./spec/rails_helper", *files), + env=_env(), ) @@ -770,6 +778,35 @@ def scenario_playoff( ) +@task(name="scenario-render-profile") +def scenario_render_profile( + c, + group_count=8, + teams_per_group=4, + playoff_teams_amount=8, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + stop_at="profiled_show", +): + """Materialize a group-stage tournament over HTTP and print tournament show profiling.""" + c.run( + "ruby script/e2e_scenarios.rb group_stage_render_profile " + f"--group-count {group_count} " + f"--teams-per-group {teams_per_group} " + f"--playoff-teams-amount {playoff_teams_amount} " + f"--stop-at {stop_at}", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + + @task(name="docker-build-production") def docker_build_production(c, tag=PRODUCTION_TAG): """Build the production Docker image.""" @@ -807,6 +844,7 @@ def docker_blackbox_up( app_image=app_image, runner_image=runner_image, postgres_image=postgres_image, + expect_profiling="false", ) _print_header("Starting production blackbox stack") @@ -873,6 +911,7 @@ def docker_blackbox_test( app_image=app_image, runner_image=runner_image, postgres_image=postgres_image, + expect_profiling="false", ) _print_header("Running HTTP E2E against production image") @@ -1601,6 +1640,7 @@ def blackbox_production( app_image=app_image, runner_image=runner_image, postgres_image=postgres_image, + expect_profiling="false", ) exit_error = None