diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6feec6f..5798ffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,12 @@ name: Build, Test, and Deploy Dashboard -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: + branches: + - main permissions: contents: read @@ -106,7 +112,6 @@ jobs: path: image-artifacts - name: Restore previously published site - if: ${{ github.event_name == 'push' }} run: | set -euo pipefail rm -rf dashboard @@ -132,12 +137,16 @@ jobs: - name: Generate dashboard env: GH_TOKEN: ${{ github.token }} + GITHUB_HEAD_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || '' }} + GITHUB_PR_NUMBER: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }} + GITHUB_PR_TITLE: ${{ github.event_name == 'pull_request' && github.event.pull_request.title || '' }} + GITHUB_PR_DRAFT: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft || '' }} run: | set -euo pipefail fpm run testboard -- \ --image-root image-artifacts \ --output dashboard \ - --branch "${{ github.ref_name }}" \ + --branch "${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}" \ --commit "${{ github.sha }}" \ --run-id "${{ github.run_id }}" \ --repo "${{ github.repository }}" \ @@ -148,14 +157,12 @@ jobs: uses: actions/configure-pages@v5 - name: Upload dashboard artifact - if: ${{ github.event_name == 'push' }} uses: actions/upload-pages-artifact@v3 with: path: './dashboard' deploy: name: Deploy to GitHub Pages - if: github.event_name == 'push' needs: dashboard runs-on: ubuntu-latest permissions: diff --git a/src/gh_api.f90 b/src/gh_api.f90 index 5e98ccc..8ec8ce0 100644 --- a/src/gh_api.f90 +++ b/src/gh_api.f90 @@ -1,166 +1,326 @@ module gh_api !! GitHub API interactions via gh CLI - use string_utils, only: trim_null - use json_utils, only: branch_metadata - implicit none - private + use string_utils, only: starts_with, str + use json_utils, only: branch_metadata + implicit none + private - public :: get_pr_info, get_pr_state + public :: get_pr_info, get_pr_state contains - subroutine get_pr_info(branch, repo, metadata, success) + subroutine get_pr_info(branch, repo, metadata, success) !! Fetch PR information for a branch using gh CLI - character(len=*), intent(in) :: branch, repo - type(branch_metadata), intent(inout) :: metadata - logical, intent(out) :: success - character(len=1024) :: cmd, temp_file, line - integer :: unit, stat, ios - character(len=512) :: gh_token + character(len=*), intent(in) :: branch, repo + type(branch_metadata), intent(inout) :: metadata + logical, intent(out) :: success + character(len=1024) :: command + character(len=512) :: gh_token + character(len=256) :: branch_query, normalized_branch, github_ref + character(len=256) :: head_ref, pr_number_env, pr_title_env, pr_draft_env + integer :: stat, pr_number, env_pr_number, ios + logical :: has_token, link_parsed, command_ok, got_env_number + + success = .false. + normalized_branch = trim(branch) + branch_query = normalized_branch + + call get_environment_variable('GITHUB_HEAD_REF', head_ref, status=stat) + if (stat == 0 .and. len_trim(head_ref) > 0) then + branch_query = trim(head_ref) + end if - success = .false. + pr_number = parse_pr_number_from_ref(normalized_branch) - ! Check if GH_TOKEN is set - call get_environment_variable('GH_TOKEN', gh_token, status=stat) - if (stat /= 0 .or. len_trim(gh_token) == 0) then - return - end if + if (pr_number <= 0) then + call get_environment_variable('GITHUB_REF', github_ref, status=stat) + if (stat == 0 .and. len_trim(github_ref) > 0) then + pr_number = parse_pr_number_from_ref(github_ref) + end if + end if - ! Create temp file for output - temp_file = '/tmp/testboard_pr.json' + got_env_number = .false. + if (pr_number <= 0) then + call get_environment_variable('GITHUB_PR_NUMBER', pr_number_env, status=stat) + if (stat == 0 .and. len_trim(pr_number_env) > 0) then + pr_number_env = adjustl(trim(pr_number_env)) + read (pr_number_env, *, iostat=ios) env_pr_number + if (ios == 0) then + pr_number = env_pr_number + got_env_number = .true. + end if + end if + end if - ! Call gh CLI - write(cmd, '(A)') 'gh pr list --repo "' // trim(repo) // '" --head "' // & - trim(branch) // '" --json number,url,title,isDraft --jq ".[0]" > "' // & - trim(temp_file) // '" 2>/dev/null' + if (pr_number > 0) then + if (metadata%pr_number <= 0) metadata%pr_number = pr_number + if (len_trim(repo) > 0) then + metadata%pr_url = 'https://github.com/'//trim(repo)//'/pull/'// & + trim(str(pr_number)) + end if + end if - call execute_command_line(trim(cmd), exitstat=stat) + call get_environment_variable('GITHUB_PR_TITLE', pr_title_env, status=stat) + if (stat == 0 .and. len_trim(pr_title_env) > 0) then + metadata%pr_title = trim(pr_title_env) + end if - if (stat /= 0) return + call get_environment_variable('GITHUB_PR_DRAFT', pr_draft_env, status=stat) + if (stat == 0 .and. len_trim(pr_draft_env) > 0) then + pr_draft_env = adjustl(trim(pr_draft_env)) + read (pr_draft_env, *, iostat=ios) metadata%pr_draft + if (ios /= 0) metadata%pr_draft = .false. + end if - ! Read JSON output (simple parsing) - open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios) - if (ios /= 0) return + link_parsed = (metadata%pr_number > 0 .and. len_trim(metadata%pr_url) > 0) - success = parse_pr_json(unit, metadata) - close(unit) + call get_environment_variable('GH_TOKEN', gh_token, status=stat) + has_token = (stat == 0 .and. len_trim(gh_token) > 0) - ! Clean up - open(newunit=unit, file=temp_file, status='old') - close(unit, status='delete') - end subroutine get_pr_info + if (.not. has_token) then + success = link_parsed + return + end if - function parse_pr_json(unit, metadata) result(success) - !! Parse JSON from gh pr list (simple parser for specific fields) - integer, intent(in) :: unit - type(branch_metadata), intent(inout) :: metadata - logical :: success - character(len=2048) :: line - integer :: ios, pos1, pos2 + if (starts_with(branch_query, 'refs/heads/')) then + branch_query = trim(branch_query(len('refs/heads/') + 1:)) + end if - success = .false. + if (pr_number > 0 .and. .not. got_env_number) then + write (command, '(A,I0,A)') 'gh pr view ', pr_number, ' --repo "'// & + trim(repo)//'" --json number,url,title,isDraft' + command_ok = run_pr_command(trim(command), metadata) + else + write (command, '(A)') 'gh pr list --repo "'//trim(repo)// & + '" --head "'//trim(branch_query)// & + '" --json number,url,title,isDraft --jq ".[0]"' + command_ok = run_pr_command(trim(command), metadata) + + if (.not. command_ok) then + pr_number = parse_pr_number_from_ref(branch_query) + if (pr_number > 0) then + write (command, '(A,I0,A)') 'gh pr view ', pr_number, ' --repo "'// & + trim(repo)//'" --json number,url,title,isDraft' + command_ok = run_pr_command(trim(command), metadata) + end if + end if + end if - do - read(unit, '(A)', iostat=ios) line - if (ios /= 0) exit + if (command_ok) then + success = .true. + if (metadata%pr_number == 0 .and. pr_number > 0) then + metadata%pr_number = pr_number + end if + if (len_trim(metadata%pr_url) == 0 .and. pr_number > 0 .and. & + len_trim(repo) > 0) then + metadata%pr_url = 'https://github.com/'//trim(repo)//'/pull/'// & + trim(str(pr_number)) + end if + else + success = link_parsed + end if + end subroutine get_pr_info - ! Parse "number": 123 - if (index(line, '"number":') > 0) then - pos1 = index(line, ':') + 1 - read(line(pos1:), *, iostat=ios) metadata%pr_number - if (ios == 0) success = .true. + function parse_pr_json(unit, metadata) result(success) + !! Parse JSON from gh pr list (simple parser for specific fields) + integer, intent(in) :: unit + type(branch_metadata), intent(inout) :: metadata + logical :: success + character(len=2048) :: line + integer :: ios, pos1, pos2 + + success = .false. + + do + read (unit, '(A)', iostat=ios) line + if (ios /= 0) exit + + ! Parse "number": 123 + if (index(line, '"number":') > 0) then + pos1 = index(line, ':') + 1 + read (line(pos1:), *, iostat=ios) metadata%pr_number + if (ios == 0) success = .true. + end if + + ! Parse "url": "..." + if (index(line, '"url":') > 0) then + pos1 = index(line, '"', back=.true.) ! last quote + pos2 = index(line(:pos1 - 1), '"', back=.true.) ! second-to-last quote + if (pos2 > 0 .and. pos1 > pos2) then + metadata%pr_url = line(pos2 + 1:pos1 - 1) + end if + end if + + ! Parse "title": "..." + if (index(line, '"title":') > 0) then + pos1 = index(line, '"', back=.true.) + pos2 = index(line(:pos1 - 1), '"', back=.true.) + if (pos2 > 0 .and. pos1 > pos2) then + metadata%pr_title = line(pos2 + 1:pos1 - 1) + end if + end if + + ! Parse "isDraft": true/false + if (index(line, '"isDraft":') > 0) then + metadata%pr_draft = (index(line, 'true') > 0) + end if + end do + end function parse_pr_json + + logical function run_pr_command(command, metadata) result(success) + !! Execute a gh command and parse PR metadata JSON output + character(len=*), intent(in) :: command + type(branch_metadata), intent(inout) :: metadata + character(len=1024) :: cmd, temp_file + integer :: unit, stat, ios + + temp_file = '/tmp/testboard_pr.json' + write (cmd, '(A)') trim(command)//' > "'//trim(temp_file)// & + '" 2>/dev/null' + + call execute_command_line(trim(cmd), exitstat=stat) + if (stat /= 0) then + success = .false. + call cleanup_temp_file(temp_file) + return end if - ! Parse "url": "..." - if (index(line, '"url":') > 0) then - pos1 = index(line, '"', back=.true.) ! last quote - pos2 = index(line(:pos1-1), '"', back=.true.) ! second-to-last quote - if (pos2 > 0 .and. pos1 > pos2) then - metadata%pr_url = line(pos2+1:pos1-1) - end if + open (newunit=unit, file=temp_file, status='old', action='read', iostat=ios) + if (ios /= 0) then + success = .false. + call cleanup_temp_file(temp_file) + return end if - ! Parse "title": "..." - if (index(line, '"title":') > 0) then - pos1 = index(line, '"', back=.true.) - pos2 = index(line(:pos1-1), '"', back=.true.) - if (pos2 > 0 .and. pos1 > pos2) then - metadata%pr_title = line(pos2+1:pos1-1) - end if + success = parse_pr_json(unit, metadata) + close (unit) + call cleanup_temp_file(temp_file) + end function run_pr_command + + subroutine cleanup_temp_file(path) + !! Delete temporary file if it exists + character(len=*), intent(in) :: path + integer :: unit, ios + + open (newunit=unit, file=path, status='old', action='read', iostat=ios) + if (ios == 0) then + close (unit, status='delete') end if + end subroutine cleanup_temp_file + + integer function parse_pr_number_from_ref(branch) result(pr_number) + !! Extract PR number from GitHub ref like refs/pull/123/merge + character(len=*), intent(in) :: branch + character(len=256) :: trimmed_branch, digits + integer :: prefix_len, slash_pos, ios - ! Parse "isDraft": true/false - if (index(line, '"isDraft":') > 0) then - metadata%pr_draft = (index(line, 'true') > 0) + pr_number = 0 + trimmed_branch = trim(branch) + + if (starts_with(trimmed_branch, 'refs/pull/')) then + prefix_len = len('refs/pull/') + else if (starts_with(trimmed_branch, 'pull/')) then + prefix_len = len('pull/') + else + return + end if + + slash_pos = index(trimmed_branch(prefix_len + 1:), '/') + if (slash_pos > 0) then + digits = trimmed_branch(prefix_len + 1:prefix_len + slash_pos - 1) + else + digits = trimmed_branch(prefix_len + 1:) end if - end do - end function parse_pr_json - function get_pr_state(pr_number, repo, state, success) result(is_open) + digits = trim(digits) + if (.not. is_all_digits(digits)) return + + read (digits, *, iostat=ios) pr_number + if (ios /= 0) pr_number = 0 + end function parse_pr_number_from_ref + + logical function is_all_digits(text) result(all_digits) + !! Check whether the provided text is composed only of digits + character(len=*), intent(in) :: text + integer :: i, code + + all_digits = (len_trim(text) > 0) + if (.not. all_digits) return + + do i = 1, len_trim(text) + code = iachar(text(i:i)) + if (code < iachar('0') .or. code > iachar('9')) then + all_digits = .false. + return + end if + end do + end function is_all_digits + + function get_pr_state(pr_number, repo, state, success) result(is_open) !! Check if a PR is open using gh CLI !! Returns .true. if PR is open, .false. if closed/merged - integer, intent(in) :: pr_number - character(len=*), intent(in) :: repo - character(len=16), intent(out) :: state - logical, intent(out) :: success - logical :: is_open - character(len=1024) :: cmd - character(len=256) :: temp_file - character(len=512) :: gh_token - character(len=64) :: line - integer :: unit, stat, ios, pos1, pos2 - - success = .false. - is_open = .true. ! Default to open (fail-safe) - state = 'unknown' - - ! Check if GH_TOKEN is set - call get_environment_variable('GH_TOKEN', gh_token, status=stat) - if (stat /= 0 .or. len_trim(gh_token) == 0) then - return - end if - - ! Create temp file for output - temp_file = '/tmp/testboard_pr_state.json' - - ! Call gh CLI to get PR state - write(cmd, '(A,I0,A)') 'gh pr view ', pr_number, ' --repo "' // trim(repo) // & - '" --json state --jq ".state" > "' // trim(temp_file) // '" 2>/dev/null' - - call execute_command_line(trim(cmd), exitstat=stat) - - if (stat /= 0) return - - ! Read state from file - open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios) - if (ios /= 0) return - - read(unit, '(A)', iostat=ios) line - if (ios == 0) then - ! Remove quotes and newlines - line = adjustl(trim(line)) - if (line(1:1) == '"') then - pos1 = 2 - pos2 = index(line(2:), '"') - if (pos2 > 0) then - state = trim(line(pos1:pos1+pos2-2)) - else - state = trim(line(2:)) - end if - else - state = trim(line) + integer, intent(in) :: pr_number + character(len=*), intent(in) :: repo + character(len=16), intent(out) :: state + logical, intent(out) :: success + logical :: is_open + character(len=1024) :: cmd + character(len=256) :: temp_file + character(len=512) :: gh_token + character(len=64) :: line + integer :: unit, stat, ios, pos1, pos2 + + success = .false. + is_open = .true. ! Default to open (fail-safe) + state = 'unknown' + + ! Check if GH_TOKEN is set + call get_environment_variable('GH_TOKEN', gh_token, status=stat) + if (stat /= 0 .or. len_trim(gh_token) == 0) then + return end if - ! Check if state is OPEN - is_open = (trim(state) == 'OPEN') - success = .true. - end if + ! Create temp file for output + temp_file = '/tmp/testboard_pr_state.json' + + ! Call gh CLI to get PR state + write (cmd, '(A,I0,A)') 'gh pr view ', pr_number, ' --repo "'//trim(repo)// & + '" --json state --jq ".state" > "'//trim(temp_file)//'" 2>/dev/null' + + call execute_command_line(trim(cmd), exitstat=stat) + + if (stat /= 0) return + + ! Read state from file + open (newunit=unit, file=temp_file, status='old', action='read', iostat=ios) + if (ios /= 0) return + + read (unit, '(A)', iostat=ios) line + if (ios == 0) then + ! Remove quotes and newlines + line = adjustl(trim(line)) + if (line(1:1) == '"') then + pos1 = 2 + pos2 = index(line(2:), '"') + if (pos2 > 0) then + state = trim(line(pos1:pos1 + pos2 - 2)) + else + state = trim(line(2:)) + end if + else + state = trim(line) + end if + + ! Check if state is OPEN + is_open = (trim(state) == 'OPEN') + success = .true. + end if - close(unit) + close (unit) - ! Clean up - open(newunit=unit, file=temp_file, status='old') - close(unit, status='delete') - end function get_pr_state + ! Clean up + open (newunit=unit, file=temp_file, status='old') + close (unit, status='delete') + end function get_pr_state end module gh_api diff --git a/src/json_utils.f90 b/src/json_utils.f90 index 6dadb1f..f5eb7e4 100644 --- a/src/json_utils.f90 +++ b/src/json_utils.f90 @@ -1,115 +1,335 @@ module json_utils !! Simple JSON reading/writing for metadata !! This is a minimal implementation for our specific use case - use string_utils, only: str - implicit none - private - - public :: json_write_metadata, json_read_metadata, branch_metadata - - type :: branch_metadata - character(len=256) :: branch_name = '' - character(len=256) :: commit = '' - character(len=256) :: timestamp = '' - character(len=64) :: run_id = '' - character(len=256) :: repo = '' - logical :: has_pngs = .false. - integer :: diff_count = 0 - integer :: pr_number = 0 - character(len=512) :: pr_url = '' - character(len=512) :: pr_title = '' - logical :: pr_draft = .false. - end type branch_metadata + use string_utils, only: str, starts_with + implicit none + private + + public :: json_write_metadata, json_read_metadata, branch_metadata + + type :: branch_metadata + character(len=256) :: branch_name = '' + character(len=256) :: commit = '' + character(len=256) :: timestamp = '' + character(len=64) :: run_id = '' + character(len=256) :: repo = '' + logical :: has_pngs = .false. + integer :: diff_count = 0 + integer :: pr_number = 0 + character(len=512) :: pr_url = '' + character(len=512) :: pr_title = '' + logical :: pr_draft = .false. + end type branch_metadata contains - subroutine json_write_metadata(filepath, branches, n_branches) + subroutine json_write_metadata(filepath, branches, n_branches) !! Write metadata to JSON file - character(len=*), intent(in) :: filepath - type(branch_metadata), intent(in) :: branches(:) - integer, intent(in) :: n_branches - integer :: unit, i + character(len=*), intent(in) :: filepath + type(branch_metadata), intent(in) :: branches(:) + integer, intent(in) :: n_branches + integer :: unit, i - open (newunit=unit, file=trim(filepath), status='replace', action='write') + open (newunit=unit, file=trim(filepath), status='replace', action='write') - write (unit, '(A)') '{' - do i = 1, n_branches - call write_branch_entry(unit, branches(i), i == n_branches) - end do - write (unit, '(A)') '}' + write (unit, '(A)') '{' + do i = 1, n_branches + call write_branch_entry(unit, branches(i), i == n_branches) + end do + write (unit, '(A)') '}' - close (unit) - end subroutine json_write_metadata + close (unit) + end subroutine json_write_metadata - subroutine write_branch_entry(unit, branch, is_last) + subroutine write_branch_entry(unit, branch, is_last) !! Write a single branch entry - integer, intent(in) :: unit - type(branch_metadata), intent(in) :: branch - logical, intent(in) :: is_last - character(len=1) :: comma - - if (is_last) then - comma = ' ' - else - comma = ',' - end if - - write (unit, '(A)') ' "'//trim(branch%branch_name)//'": {' - write (unit, '(A)') ' "commit": "'//trim(branch%commit)//'",' - write (unit, '(A)') ' "has_pngs": '// & - merge('true ', 'false', branch%has_pngs)//',' - write (unit, '(A)') ' "diff_count": '//trim(str(branch%diff_count))//',' - write (unit, '(A)') ' "path": "'//trim(branch%branch_name)//'",' - write (unit, '(A)') ' "repo": "'//trim(branch%repo)//'",' - write (unit, '(A)') ' "run_id": "'//trim(branch%run_id)//'",' - write (unit, '(A)') ' "updated": "'//trim(branch%timestamp)//'"' - - if (branch%pr_number > 0) then - write (unit, '(A)') ' ,' - write (unit, '(A)') ' "pr_info": {' - write (unit, '(A,I0,A)') ' "number": ', branch%pr_number, ',' - write (unit, '(A)') ' "url": "'//trim(branch%pr_url)//'",' - write (unit, '(A)') ' "title": "'//trim(branch%pr_title)//'",' + integer, intent(in) :: unit + type(branch_metadata), intent(in) :: branch + logical, intent(in) :: is_last + character(len=1) :: comma + + if (is_last) then + comma = ' ' + else + comma = ',' + end if + + write (unit, '(A)') ' "'//trim(branch%branch_name)//'": {' + write (unit, '(A)') ' "commit": "'//trim(branch%commit)//'",' + write (unit, '(A)') ' "has_pngs": '// & + merge('true ', 'false', branch%has_pngs)//',' + write (unit, '(A)') ' "diff_count": '//trim(str(branch%diff_count))//',' + write (unit, '(A)') ' "path": "'//trim(branch%branch_name)//'",' + write (unit, '(A)') ' "repo": "'//trim(branch%repo)//'",' + write (unit, '(A)') ' "run_id": "'//trim(branch%run_id)//'",' + write (unit, '(A)') ' "updated": "'//trim(branch%timestamp)//'"' + + if (branch%pr_number > 0) then + write (unit, '(A)') ' ,' + write (unit, '(A)') ' "pr_info": {' + write (unit, '(A,I0,A)') ' "number": ', branch%pr_number, ',' + write (unit, '(A)') ' "url": "'//trim(branch%pr_url)//'",' + write (unit, '(A)') ' "title": "'//trim(branch%pr_title)//'",' write (unit, '(A)') ' "draft": '//merge('true ', 'false', branch%pr_draft) - write (unit, '(A)') ' }' - end if - - write (unit, '(A)') ' }'//comma - end subroutine write_branch_entry - - subroutine json_read_metadata(filepath, branches, n_branches, max_branches) - !! Read metadata from JSON file (simplified parser) - character(len=*), intent(in) :: filepath - type(branch_metadata), intent(out) :: branches(:) - integer, intent(out) :: n_branches - integer, intent(in), optional :: max_branches - integer :: unit, ios, max_n - character(len=2048) :: line - logical :: exists - - if (present(max_branches)) then - max_n = max_branches - else - max_n = size(branches) - end if - - inquire (file=trim(filepath), exist=exists) - if (.not. exists) then - n_branches = 0 - return - end if - - n_branches = 0 - open (newunit=unit, file=trim(filepath), status='old', action='read') - do - read (unit, '(A)', iostat=ios) line - if (ios /= 0) exit - if (index(line, '"path":') > 0) n_branches = n_branches + 1 - if (n_branches >= max_n) exit - end do - close (unit) - - ! For now, we'll keep this simple and regenerate from scratch - end subroutine json_read_metadata + write (unit, '(A)') ' }' + end if + + write (unit, '(A)') ' }'//comma + end subroutine write_branch_entry + + subroutine json_read_metadata(filepath, branches, n_branches, max_branches) + !! Read metadata from JSON file written by json_write_metadata + character(len=*), intent(in) :: filepath + type(branch_metadata), intent(out) :: branches(:) + integer, intent(out) :: n_branches + integer, intent(in), optional :: max_branches + integer :: unit, ios, max_n, current_index + character(len=2048) :: line + character(len=:), allocatable :: trimmed + logical :: exists, in_branch, in_pr_info + + if (present(max_branches)) then + max_n = max_branches + else + max_n = size(branches) + end if + + inquire (file=trim(filepath), exist=exists) + if (.not. exists) then + n_branches = 0 + return + end if + + n_branches = 0 + current_index = 0 + in_branch = .false. + in_pr_info = .false. + + open (newunit=unit, file=trim(filepath), status='old', action='read') + do + read (unit, '(A)', iostat=ios) line + if (ios /= 0) exit + + trimmed = adjustl(trim(line)) + + if (.not. in_branch) then + if (is_branch_header(trimmed)) then + if (n_branches >= max_n) exit + n_branches = n_branches + 1 + current_index = n_branches + branches(current_index) = branch_metadata() + call parse_branch_name(trimmed, branches(current_index)%branch_name) + in_branch = .true. + end if + cycle + end if + + if (.not. in_pr_info .and. starts_with(trimmed, '"pr_info"')) then + in_pr_info = .true. + cycle + end if + + if (in_pr_info) then + if (is_section_end(trimmed)) then + in_pr_info = .false. + cycle + end if + call parse_pr_field(trimmed, branches(current_index)) + cycle + end if + + if (is_section_end(trimmed)) then + in_branch = .false. + current_index = 0 + cycle + end if + + call parse_branch_field(trimmed, branches(current_index)) + end do + close (unit) + + if (n_branches > max_n) n_branches = max_n + end subroutine json_read_metadata + + logical function is_branch_header(line) result(header) + character(len=*), intent(in) :: line + + header = (index(line, '": {') > 0 .and. index(line, '"pr_info"') == 0 .and. & + index(line, '{') > 0) + end function is_branch_header + + logical function is_section_end(line) result(ended) + character(len=*), intent(in) :: line + + ended = (trim(line) == '}' .or. trim(line) == '},' .or. trim(line) == '}'//char(0)) + end function is_section_end + + subroutine parse_branch_name(line, name) + character(len=*), intent(in) :: line + character(len=*), intent(inout) :: name + integer :: first_quote, second_quote + + first_quote = index(line, '"') + if (first_quote <= 0) then + name = '' + return + end if + + second_quote = index(line(first_quote + 1:), '"') + if (second_quote <= 0) then + name = '' + return + end if + + name = line(first_quote + 1:first_quote + second_quote - 1) + end subroutine parse_branch_name + + subroutine parse_branch_field(line, branch) + character(len=*), intent(in) :: line + type(branch_metadata), intent(inout) :: branch + character(len=512) :: value + integer :: number, ios + logical :: logical_value + + if (extract_json_string(line, '"commit"', value)) then + branch%commit = trim(value) + else if (extract_json_string(line, '"path"', value)) then + branch%branch_name = trim(value) + else if (extract_json_string(line, '"repo"', value)) then + branch%repo = trim(value) + else if (extract_json_string(line, '"run_id"', value)) then + branch%run_id = trim(value) + else if (extract_json_string(line, '"updated"', value)) then + branch%timestamp = trim(value) + else if (extract_json_integer(line, '"diff_count"', number)) then + branch%diff_count = number + else if (extract_json_integer(line, '"diff"', number)) then + branch%diff_count = number + else if (extract_json_logical(line, '"has_pngs"', logical_value)) then + branch%has_pngs = logical_value + end if + end subroutine parse_branch_field + + subroutine parse_pr_field(line, branch) + character(len=*), intent(in) :: line + type(branch_metadata), intent(inout) :: branch + character(len=512) :: value + integer :: number + logical :: logical_value + + if (extract_json_integer(line, '"number"', number)) then + branch%pr_number = number + else if (extract_json_string(line, '"url"', value)) then + branch%pr_url = trim(value) + else if (extract_json_string(line, '"title"', value)) then + branch%pr_title = trim(value) + else if (extract_json_logical(line, '"draft"', logical_value)) then + branch%pr_draft = logical_value + end if + end subroutine parse_pr_field + + logical function extract_json_string(line, field, value) result(found) + character(len=*), intent(in) :: line + character(len=*), intent(in) :: field + character(len=*), intent(out) :: value + integer :: pos, first_quote, second_quote, colon_pos + + value = '' + pos = index(line, field) + if (pos == 0) then + found = .false. + return + end if + + colon_pos = index(line(pos:), ':') + if (colon_pos == 0) then + found = .false. + return + end if + pos = pos + colon_pos - 1 + + first_quote = index(line(pos + 1:), '"') + if (first_quote == 0) then + found = .false. + return + end if + first_quote = pos + first_quote + second_quote = index(line(first_quote + 1:), '"') + if (second_quote == 0) then + found = .false. + return + end if + second_quote = first_quote + second_quote + + value = line(first_quote + 1:second_quote - 1) + found = .true. + end function extract_json_string + + logical function extract_json_integer(line, field, value) result(found) + character(len=*), intent(in) :: line + character(len=*), intent(in) :: field + integer, intent(out) :: value + integer :: pos, colon_pos, ios + character(len=256) :: buffer + + value = 0 + pos = index(line, field) + if (pos == 0) then + found = .false. + return + end if + + colon_pos = index(line(pos:), ':') + if (colon_pos == 0) then + found = .false. + return + end if + + colon_pos = pos + colon_pos - 1 + + buffer = adjustl(trim(line(colon_pos + 1:))) + call strip_trailing_comma(buffer) + read (buffer, *, iostat=ios) value + found = (ios == 0) + end function extract_json_integer + + logical function extract_json_logical(line, field, value) result(found) + character(len=*), intent(in) :: line + character(len=*), intent(in) :: field + logical, intent(out) :: value + integer :: pos, colon_pos, ios + character(len=256) :: buffer + + value = .false. + pos = index(line, field) + if (pos == 0) then + found = .false. + return + end if + + colon_pos = index(line(pos:), ':') + if (colon_pos == 0) then + found = .false. + return + end if + + colon_pos = pos + colon_pos - 1 + + buffer = adjustl(trim(line(colon_pos + 1:))) + call strip_trailing_comma(buffer) + read (buffer, *, iostat=ios) value + found = (ios == 0) + end function extract_json_logical + + subroutine strip_trailing_comma(text) + character(len=*), intent(inout) :: text + integer :: len_trimmed + + len_trimmed = len_trim(text) + if (len_trimmed <= 0) return + if (text(len_trimmed:len_trimmed) == ',') then + text(len_trimmed:len_trimmed) = ' ' + end if + end subroutine strip_trailing_comma end module json_utils diff --git a/test/test_integration.f90 b/test/test_integration.f90 index 99bd8e1..78b4574 100644 --- a/test/test_integration.f90 +++ b/test/test_integration.f90 @@ -1,323 +1,459 @@ program test_integration !! Integration tests - test dashboard generation with mock data - use dashboard - use file_utils - implicit none - - integer :: num_tests, num_passed - integer, parameter :: basic_png_bytes(68) = [integer :: & + use dashboard + use file_utils + use iso_c_binding, only: c_char, c_null_char, c_int + implicit none + + integer :: num_tests, num_passed + integer, parameter :: basic_png_bytes(68) = [integer :: & + 137, 80, 78, 71, 13, 10, 26, 10, & + 0, 0, 0, 13, 73, 72, 68, 82, & + 0, 0, 0, 1, 0, 0, 0, 1, & + 8, 4, 0, 0, 0, 181, 28, 12, & + 2, 0, 0, 0, 11, 73, 68, 65, & + 84, 120, 218, 99, 252, 255, 31, 0, & + 3, 3, 2, 0, 238, 103, 208, 90, & + 0, 0, 0, 0, 73, 69, 78, 68, & + 174, 66, 96, 130] + integer, parameter :: fancy_png_bytes(104) = [integer :: & 137, 80, 78, 71, 13, 10, 26, 10, & 0, 0, 0, 13, 73, 72, 68, 82, & - 0, 0, 0, 1, 0, 0, 0, 1, & - 8, 4, 0, 0, 0, 181, 28, 12, & - 2, 0, 0, 0, 11, 73, 68, 65, & - 84, 120, 218, 99, 252, 255, 31, 0, & - 3, 3, 2, 0, 238, 103, 208, 90, & - 0, 0, 0, 0, 73, 69, 78, 68, & - 174, 66, 96, 130] - integer, parameter :: fancy_png_bytes(104) = [integer :: & - 137, 80, 78, 71, 13, 10, 26, 10, & - 0, 0, 0, 13, 73, 72, 68, 82, & - 0, 0, 0, 32, 0, 0, 0, 32, & - 8, 6, 0, 0, 0, 115, 122, 122, & - 244, 0, 0, 0, 47, 73, 68, 65, & - 84, 120, 156, 237, 206, 49, 1, 0, & - 48, 12, 128, 48, 54, 255, 158, 91, & - 25, 125, 130, 1, 242, 166, 166, 195, & - 254, 229, 28, 0, 0, 0, 0, 0, & - 0, 0, 0, 0, 0, 0, 0, 0, & - 0, 160, 106, 1, 48, 227, 2, 62, & - 54, 153, 94, 177, 0, 0, 0, 0, & - 73, 69, 78, 68, 174, 66, 96, 130] - logical :: success - - num_tests = 0 - num_passed = 0 - - call test_dashboard_generation() - call test_multiple_branches() - call test_nested_branch_gallery() - call test_diff_detection() - - print *, '' - print *, 'Integration Tests: ', num_passed, '/', num_tests, ' passed' - - if (num_passed /= num_tests) then - stop 1 - end if + 0, 0, 0, 32, 0, 0, 0, 32, & + 8, 6, 0, 0, 0, 115, 122, 122, & + 244, 0, 0, 0, 47, 73, 68, 65, & + 84, 120, 156, 237, 206, 49, 1, 0, & + 48, 12, 128, 48, 54, 255, 158, 91, & + 25, 125, 130, 1, 242, 166, 166, 195, & + 254, 229, 28, 0, 0, 0, 0, 0, & + 0, 0, 0, 0, 0, 0, 0, 0, & + 0, 160, 106, 1, 48, 227, 2, 62, & + 54, 153, 94, 177, 0, 0, 0, 0, & + 73, 69, 78, 68, 174, 66, 96, 130] + interface + function c_setenv(name, value, overwrite) bind(C, name="setenv") & + result(ret) + use iso_c_binding, only: c_char, c_int + character(kind=c_char), dimension(*), intent(in) :: name + character(kind=c_char), dimension(*), intent(in) :: value + integer(c_int), value :: overwrite + integer(c_int) :: ret + end function c_setenv + + function c_unsetenv(name) bind(C, name="unsetenv") result(ret) + use iso_c_binding, only: c_char, c_int + character(kind=c_char), dimension(*), intent(in) :: name + integer(c_int) :: ret + end function c_unsetenv + end interface + logical :: success + + num_tests = 0 + num_passed = 0 + + call test_dashboard_generation() + call test_multiple_branches() + call test_nested_branch_gallery() + call test_diff_detection() + call test_pr_ref_branch() + + print *, '' + print *, 'Integration Tests: ', num_passed, '/', num_tests, ' passed' + + if (num_passed /= num_tests) then + stop 1 + end if contains - subroutine test_dashboard_generation() - type(dashboard_config) :: config - logical :: exists - - num_tests = num_tests + 1 - - ! Setup test config - config%image_root = 'test_artifacts' - config%output_dir = 'test_output' - config%branch_name = 'test-branch' - config%commit_sha = 'abc123def456' - config%run_id = '12345' - config%repo = 'test-org/test-repo' - config%project_name = 'Test Project' - - ! Create mock image directory (empty for this test) - call create_directory(config%image_root, success) - - ! Generate dashboard - call generate_dashboard(config, success) - - if (success .and. & - file_exists(trim(config%output_dir)//'/test/test-branch/index.html') .and. & - file_exists(trim(config%output_dir)//'/test/test-branch/diff.html') .and. & - file_exists(trim(config%output_dir)//'/test/index.html') .and. & - file_exists(trim(config%output_dir)//'/index.html')) then - num_passed = num_passed + 1 - print *, '[PASS] dashboard_generation: creates all files' - else - print *, '[FAIL] dashboard_generation: creates all files' - print *, ' Success: ', success - print *, ' Branch page: ', & - file_exists(trim(config%output_dir)//'/test/test-branch/index.html') - print *, ' Diff page: ', & - file_exists(trim(config%output_dir)//'/test/test-branch/diff.html') - print *, ' Overview page: ', & - file_exists(trim(config%output_dir)//'/test/index.html') - print *, ' Root page: ', & - file_exists(trim(config%output_dir)//'/index.html') - end if - - ! Cleanup - call remove_directory(config%image_root, success) - call remove_directory(config%output_dir, success) - end subroutine test_dashboard_generation - - subroutine test_multiple_branches() - type(dashboard_config) :: config - logical :: exists - - num_tests = num_tests + 1 - - ! Setup test config - config%image_root = 'test_artifacts' - config%output_dir = 'test_output' - config%repo = 'test-org/test-repo' - config%project_name = 'Test Project' - - call create_directory(config%image_root, success) - - ! Generate dashboard for branch 1 - config%branch_name = 'main' - config%commit_sha = 'abc123' - config%run_id = '11111' - call generate_dashboard(config, success) - - ! Generate dashboard for branch 2 - config%branch_name = 'feature' - config%commit_sha = 'def456' - config%run_id = '22222' - call generate_dashboard(config, success) - - ! Check both branches exist - if (success .and. & - file_exists(trim(config%output_dir)//'/test/main/index.html') .and. & - file_exists(trim(config%output_dir)//'/test/main/diff.html') .and. & - file_exists(trim(config%output_dir)//'/test/feature/index.html') .and. & - file_exists(trim(config%output_dir)//'/test/feature/diff.html') .and. & - file_exists(trim(config%output_dir)//'/test/branches.json')) then - num_passed = num_passed + 1 - print *, '[PASS] multiple_branches: both branches exist' - else - print *, '[FAIL] multiple_branches: both branches exist' - end if - - ! Cleanup - call remove_directory(config%image_root, success) - call remove_directory(config%output_dir, success) - end subroutine test_multiple_branches - - subroutine test_nested_branch_gallery() - type(dashboard_config) :: config - character(len=:), allocatable :: branch_dir, branch_file - character(len=:), allocatable :: fancy_file, basic_file - logical :: run_success, fancy_exists, basic_exists, link_ok, style_ok, diff_nav_ok - integer :: unit, ios - character(len=1024) :: line - - num_tests = num_tests + 1 - - config%image_root = 'test_artifacts' - config%output_dir = 'test_output' - config%branch_name = 'feat/support-png-jpg-images' - config%commit_sha = 'feedfacecafebeef' - config%run_id = '33333' - config%repo = 'test-org/test-repo' - config%project_name = 'Test Project' - - call create_directory(trim(config%image_root)//'/basic', run_success) - call create_directory(trim(config%image_root)//'/fancy', run_success) - - call write_png_fixture(trim(config%image_root)//'/basic/chart.png', & - basic_png_bytes) - call write_png_fixture(trim(config%image_root)//'/fancy/chart.PNG', & - fancy_png_bytes) - - call generate_dashboard(config, run_success) - - branch_dir = trim(config%output_dir)//'/test/feat/support-png-jpg-images' - branch_file = trim(branch_dir)//'/index.html' - fancy_file = trim(branch_dir)//'/images/fancy/chart.PNG' - basic_file = trim(branch_dir)//'/images/basic/chart.png' - - fancy_exists = file_exists(fancy_file) - basic_exists = file_exists(basic_file) - - link_ok = .false. - style_ok = .false. - diff_nav_ok = .false. - open (newunit=unit, file=branch_file, status='old', action='read', iostat=ios) - if (ios == 0) then - do - read (unit, '(A)', iostat=ios) line - if (ios /= 0) exit - if (index(line, 'href="../../index.html"') > 0) link_ok = .true. - if (index(line, 'class="diff-link"') > 0) diff_nav_ok = .true. - if (index(line, 'gallery-item diff') > 0) style_ok = .true. - if (link_ok .and. style_ok .and. diff_nav_ok) exit - end do - close (unit) - end if - - if (run_success .and. fancy_exists .and. basic_exists .and. link_ok .and. & - style_ok .and. diff_nav_ok) then - num_passed = num_passed + 1 - print *, '[PASS] nested_branch_gallery: preserves fancy outputs and link' - else - print *, '[FAIL] nested_branch_gallery: preserves fancy outputs and link' - print *, ' Success: ', run_success - print *, ' Fancy exists: ', fancy_exists - print *, ' Basic exists: ', basic_exists - print *, ' Back link ok: ', link_ok - print *, ' Diff nav ok: ', diff_nav_ok - print *, ' Diff highlight ok: ', style_ok - end if - - call remove_directory(config%image_root, run_success) - call remove_directory(config%output_dir, run_success) - end subroutine test_nested_branch_gallery - - subroutine test_diff_detection() - type(dashboard_config) :: config - character(len=:), allocatable :: feature_dir, diff_file, gallery_file, overview_file - logical :: run_success, diff_exists, highlight_found, diff_link_found - logical :: diff_page_has_image, diff_count_ok - integer :: unit, ios - character(len=1024) :: line - - num_tests = num_tests + 1 - - config%image_root = 'test_artifacts' - config%output_dir = 'test_output' - config%repo = 'test-org/test-repo' - config%project_name = 'Test Project' - config%base_branch = 'main' - - call create_directory(config%image_root, run_success) - - config%branch_name = 'main' - config%commit_sha = 'aaa111' - config%run_id = '44444' - call write_png_fixture(trim(config%image_root)//'/chart.png', basic_png_bytes) - call generate_dashboard(config, run_success) - - config%branch_name = 'feature-diff' - config%commit_sha = 'bbb222' - config%run_id = '55555' - call write_png_fixture(trim(config%image_root)//'/chart.png', fancy_png_bytes) - call generate_dashboard(config, run_success) - - feature_dir = trim(config%output_dir)//'/test/feature-diff' - diff_file = trim(feature_dir)//'/diff.html' - gallery_file = trim(feature_dir)//'/index.html' - overview_file = trim(config%output_dir)//'/test/index.html' - - diff_exists = file_exists(diff_file) - highlight_found = .false. - diff_link_found = .false. - diff_page_has_image = .false. - diff_count_ok = .false. - - open (newunit=unit, file=gallery_file, status='old', action='read', iostat=ios) - if (ios == 0) then - do - read (unit, '(A)', iostat=ios) line - if (ios /= 0) exit - if (index(line, 'gallery-item diff') > 0) highlight_found = .true. - end do - close (unit) - end if - - open (newunit=unit, file=overview_file, status='old', action='read', iostat=ios) - if (ios == 0) then - do - read (unit, '(A)', iostat=ios) line - if (ios /= 0) exit - if (index(line, 'feature-diff/diff.html') > 0) then - diff_link_found = .true. - if (index(line, 'diff (1)') > 0) diff_count_ok = .true. - end if - end do - close (unit) - end if - - open (newunit=unit, file=diff_file, status='old', action='read', iostat=ios) - if (ios == 0) then + function to_c_string(str) result(buffer) + character(len=*), intent(in) :: str + character(kind=c_char), allocatable :: buffer(:) + integer :: n, i + + n = len_trim(str) + allocate (buffer(1:max(1, n + 1))) + if (n > 0) then + do i = 1, n + buffer(i) = char(iachar(str(i:i)), kind=c_char) + end do + buffer(n + 1) = c_null_char + else + buffer(1) = c_null_char + end if + end function to_c_string + + subroutine set_env(name, value) + character(len=*), intent(in) :: name, value + character(kind=c_char), allocatable :: name_c(:), value_c(:) + integer(c_int) :: ret + + name_c = to_c_string(name) + value_c = to_c_string(value) + ret = c_setenv(name_c, value_c, 1_c_int) + end subroutine set_env + + subroutine clear_env(name) + character(len=*), intent(in) :: name + character(kind=c_char), allocatable :: name_c(:) + integer(c_int) :: ret + + name_c = to_c_string(name) + ret = c_unsetenv(name_c) + end subroutine clear_env + + subroutine test_dashboard_generation() + type(dashboard_config) :: config + logical :: exists + + num_tests = num_tests + 1 + + ! Setup test config + config%image_root = 'test_artifacts' + config%output_dir = 'test_output' + config%branch_name = 'test-branch' + config%commit_sha = 'abc123def456' + config%run_id = '12345' + config%repo = 'test-org/test-repo' + config%project_name = 'Test Project' + + ! Create mock image directory (empty for this test) + call create_directory(config%image_root, success) + + ! Generate dashboard + call generate_dashboard(config, success) + + if (success .and. & + file_exists(trim(config%output_dir)//'/test/test-branch/index.html') .and. & + file_exists(trim(config%output_dir)//'/test/test-branch/diff.html') .and. & + file_exists(trim(config%output_dir)//'/test/index.html') .and. & + file_exists(trim(config%output_dir)//'/index.html')) then + num_passed = num_passed + 1 + print *, '[PASS] dashboard_generation: creates all files' + else + print *, '[FAIL] dashboard_generation: creates all files' + print *, ' Success: ', success + print *, ' Branch page: ', & + file_exists(trim(config%output_dir)//'/test/test-branch/index.html') + print *, ' Diff page: ', & + file_exists(trim(config%output_dir)//'/test/test-branch/diff.html') + print *, ' Overview page: ', & + file_exists(trim(config%output_dir)//'/test/index.html') + print *, ' Root page: ', & + file_exists(trim(config%output_dir)//'/index.html') + end if + + ! Cleanup + call remove_directory(config%image_root, success) + call remove_directory(config%output_dir, success) + end subroutine test_dashboard_generation + + subroutine test_multiple_branches() + type(dashboard_config) :: config + logical :: main_found, feature_found + integer :: unit_meta, ios + character(len=1024) :: meta_line + + num_tests = num_tests + 1 + + ! Setup test config + config%image_root = 'test_artifacts' + config%output_dir = 'test_output' + config%repo = 'test-org/test-repo' + config%project_name = 'Test Project' + + call create_directory(config%image_root, success) + + ! Generate dashboard for branch 1 + config%branch_name = 'main' + config%commit_sha = 'abc123' + config%run_id = '11111' + call generate_dashboard(config, success) + + ! Generate dashboard for branch 2 + config%branch_name = 'feature' + config%commit_sha = 'def456' + config%run_id = '22222' + call generate_dashboard(config, success) + + ! Check both branches exist + main_found = .false. + feature_found = .false. + if (file_exists(trim(config%output_dir)//'/test/branches.json')) then + open (newunit=unit_meta, file=trim(config%output_dir)//'/test/branches.json', & + status='old', action='read', iostat=ios) + if (ios == 0) then do - read (unit, '(A)', iostat=ios) line - if (ios /= 0) exit - if (index(line, ' 0) then - diff_page_has_image = .true. - exit - end if + read (unit_meta, '(A)', iostat=ios) meta_line + if (ios /= 0) exit + if (index(meta_line, '"main"') > 0) main_found = .true. + if (index(meta_line, '"feature"') > 0) feature_found = .true. + if (main_found .and. feature_found) exit end do - close (unit) - end if - - if (diff_exists .and. highlight_found .and. diff_link_found .and. diff_page_has_image .and. diff_count_ok) then - num_passed = num_passed + 1 - print *, '[PASS] diff_detection: highlights differing artifacts' - else - print *, '[FAIL] diff_detection: highlights differing artifacts' - print *, ' diff page exists: ', diff_exists - print *, ' gallery highlights diff: ', highlight_found - print *, ' overview diff link: ', diff_link_found - print *, ' diff page shows image: ', diff_page_has_image - print *, ' diff count shown: ', diff_count_ok - end if - - call remove_directory(config%image_root, run_success) - call remove_directory(config%output_dir, run_success) - end subroutine test_diff_detection - - subroutine write_png_fixture(path, data) - character(len=*), intent(in) :: path - integer, intent(in) :: data(:) - integer :: unit, ios, i - character(len=1) :: ch - - open (newunit=unit, file=trim(path), status='replace', access='stream', & - form='unformatted', action='write', iostat=ios) - if (ios /= 0) return - - do i = 1, size(data) - ch = achar(data(i)) - write (unit, iostat=ios) ch + close (unit_meta) + end if + end if + + if (success .and. & + file_exists(trim(config%output_dir)//'/test/main/index.html') .and. & + file_exists(trim(config%output_dir)//'/test/main/diff.html') .and. & + file_exists(trim(config%output_dir)//'/test/feature/index.html') .and. & + file_exists(trim(config%output_dir)//'/test/feature/diff.html') .and. & + file_exists(trim(config%output_dir)//'/test/branches.json') .and. & + main_found .and. feature_found) then + num_passed = num_passed + 1 + print *, '[PASS] multiple_branches: both branches exist' + else + print *, '[FAIL] multiple_branches: both branches exist' + print *, ' Metadata main: ', main_found + print *, ' Metadata feature: ', feature_found + end if + + ! Cleanup + call remove_directory(config%image_root, success) + call remove_directory(config%output_dir, success) + end subroutine test_multiple_branches + + subroutine test_nested_branch_gallery() + type(dashboard_config) :: config + character(len=:), allocatable :: branch_dir, branch_file + character(len=:), allocatable :: fancy_file, basic_file + logical :: run_success, fancy_exists, basic_exists, link_ok, style_ok, diff_nav_ok + integer :: unit, ios + character(len=1024) :: line + + num_tests = num_tests + 1 + + config%image_root = 'test_artifacts' + config%output_dir = 'test_output' + config%branch_name = 'feat/support-png-jpg-images' + config%commit_sha = 'feedfacecafebeef' + config%run_id = '33333' + config%repo = 'test-org/test-repo' + config%project_name = 'Test Project' + + call create_directory(trim(config%image_root)//'/basic', run_success) + call create_directory(trim(config%image_root)//'/fancy', run_success) + + call write_png_fixture(trim(config%image_root)//'/basic/chart.png', & + basic_png_bytes) + call write_png_fixture(trim(config%image_root)//'/fancy/chart.PNG', & + fancy_png_bytes) + + call generate_dashboard(config, run_success) + + branch_dir = trim(config%output_dir)//'/test/feat/support-png-jpg-images' + branch_file = trim(branch_dir)//'/index.html' + fancy_file = trim(branch_dir)//'/images/fancy/chart.PNG' + basic_file = trim(branch_dir)//'/images/basic/chart.png' + + fancy_exists = file_exists(fancy_file) + basic_exists = file_exists(basic_file) + + link_ok = .false. + style_ok = .false. + diff_nav_ok = .false. + open (newunit=unit, file=branch_file, status='old', action='read', iostat=ios) + if (ios == 0) then + do + read (unit, '(A)', iostat=ios) line if (ios /= 0) exit - end do - - close (unit) - end subroutine write_png_fixture + if (index(line, 'href="../../index.html"') > 0) link_ok = .true. + if (index(line, 'class="diff-link"') > 0) diff_nav_ok = .true. + if (index(line, 'gallery-item diff') > 0) style_ok = .true. + if (link_ok .and. style_ok .and. diff_nav_ok) exit + end do + close (unit) + end if + + if (run_success .and. fancy_exists .and. basic_exists .and. link_ok .and. & + style_ok .and. diff_nav_ok) then + num_passed = num_passed + 1 + print *, '[PASS] nested_branch_gallery: preserves fancy outputs and link' + else + print *, '[FAIL] nested_branch_gallery: preserves fancy outputs and link' + print *, ' Success: ', run_success + print *, ' Fancy exists: ', fancy_exists + print *, ' Basic exists: ', basic_exists + print *, ' Back link ok: ', link_ok + print *, ' Diff nav ok: ', diff_nav_ok + print *, ' Diff highlight ok: ', style_ok + end if + + call remove_directory(config%image_root, run_success) + call remove_directory(config%output_dir, run_success) + end subroutine test_nested_branch_gallery + + subroutine test_diff_detection() + type(dashboard_config) :: config + character(len=:), allocatable :: feature_dir, diff_file, gallery_file, overview_file + logical :: run_success, diff_exists, highlight_found, diff_link_found + logical :: diff_page_has_image, diff_count_ok + integer :: unit, ios + character(len=1024) :: line + + num_tests = num_tests + 1 + + config%image_root = 'test_artifacts' + config%output_dir = 'test_output' + config%repo = 'test-org/test-repo' + config%project_name = 'Test Project' + config%base_branch = 'main' + + call create_directory(config%image_root, run_success) + + config%branch_name = 'main' + config%commit_sha = 'aaa111' + config%run_id = '44444' + call write_png_fixture(trim(config%image_root)//'/chart.png', basic_png_bytes) + call generate_dashboard(config, run_success) + + config%branch_name = 'feature-diff' + config%commit_sha = 'bbb222' + config%run_id = '55555' + call write_png_fixture(trim(config%image_root)//'/chart.png', fancy_png_bytes) + call generate_dashboard(config, run_success) + + feature_dir = trim(config%output_dir)//'/test/feature-diff' + diff_file = trim(feature_dir)//'/diff.html' + gallery_file = trim(feature_dir)//'/index.html' + overview_file = trim(config%output_dir)//'/test/index.html' + + diff_exists = file_exists(diff_file) + highlight_found = .false. + diff_link_found = .false. + diff_page_has_image = .false. + diff_count_ok = .false. + + open (newunit=unit, file=gallery_file, status='old', action='read', iostat=ios) + if (ios == 0) then + do + read (unit, '(A)', iostat=ios) line + if (ios /= 0) exit + if (index(line, 'gallery-item diff') > 0) highlight_found = .true. + end do + close (unit) + end if + + open (newunit=unit, file=overview_file, status='old', action='read', iostat=ios) + if (ios == 0) then + do + read (unit, '(A)', iostat=ios) line + if (ios /= 0) exit + if (index(line, 'feature-diff/diff.html') > 0) then + diff_link_found = .true. + if (index(line, 'diff (1)') > 0) diff_count_ok = .true. + end if + end do + close (unit) + end if + + open (newunit=unit, file=diff_file, status='old', action='read', iostat=ios) + if (ios == 0) then + do + read (unit, '(A)', iostat=ios) line + if (ios /= 0) exit + if (index(line, ' 0) then + diff_page_has_image = .true. + exit + end if + end do + close (unit) + end if + + if (diff_exists .and. highlight_found .and. diff_link_found .and. diff_page_has_image .and. diff_count_ok) then + num_passed = num_passed + 1 + print *, '[PASS] diff_detection: highlights differing artifacts' + else + print *, '[FAIL] diff_detection: highlights differing artifacts' + print *, ' diff page exists: ', diff_exists + print *, ' gallery highlights diff: ', highlight_found + print *, ' overview diff link: ', diff_link_found + print *, ' diff page shows image: ', diff_page_has_image + print *, ' diff count shown: ', diff_count_ok + end if + + call remove_directory(config%image_root, run_success) + call remove_directory(config%output_dir, run_success) + end subroutine test_diff_detection + + subroutine test_pr_ref_branch() + type(dashboard_config) :: config + character(len=:), allocatable :: overview_file + logical :: run_success, pr_link_found + integer :: unit, ios + character(len=1024) :: line + + num_tests = num_tests + 1 + + config%image_root = 'test_artifacts' + config%output_dir = 'test_output' + config%branch_name = 'feature/graphics' + config%commit_sha = 'c0ffee' + config%run_id = '99999' + config%repo = 'test-org/test-repo' + config%project_name = 'Test Project' + + call set_env('GITHUB_REF', 'refs/pull/101/merge') + call set_env('GITHUB_PR_NUMBER', '101') + call set_env('GITHUB_PR_TITLE', 'Improve shading') + call set_env('GITHUB_PR_DRAFT', 'false') + call set_env('GITHUB_HEAD_REF', trim(config%branch_name)) + + call create_directory(config%image_root, run_success) + call generate_dashboard(config, run_success) + + overview_file = trim(config%output_dir)//'/test/index.html' + pr_link_found = .false. + + open (newunit=unit, file=overview_file, status='old', action='read', iostat=ios) + if (ios == 0) then + do + read (unit, '(A)', iostat=ios) line + if (ios /= 0) exit + if (index(line, 'pull/101') > 0) then + pr_link_found = .true. + exit + end if + end do + close (unit) + end if + + if (run_success .and. pr_link_found) then + num_passed = num_passed + 1 + print *, '[PASS] pr_ref_branch: adds PR link for refs/pull branch' + else + print *, '[FAIL] pr_ref_branch: adds PR link for refs/pull branch' + print *, ' Success: ', run_success + print *, ' Overview found: ', pr_link_found + end if + + call remove_directory(config%image_root, run_success) + call remove_directory(config%output_dir, run_success) + call clear_env('GITHUB_REF') + call clear_env('GITHUB_PR_NUMBER') + call clear_env('GITHUB_PR_TITLE') + call clear_env('GITHUB_PR_DRAFT') + call clear_env('GITHUB_HEAD_REF') + end subroutine test_pr_ref_branch + + subroutine write_png_fixture(path, data) + character(len=*), intent(in) :: path + integer, intent(in) :: data(:) + integer :: unit, ios, i + character(len=1) :: ch + + open (newunit=unit, file=trim(path), status='replace', access='stream', & + form='unformatted', action='write', iostat=ios) + if (ios /= 0) return + + do i = 1, size(data) + ch = achar(data(i)) + write (unit, iostat=ios) ch + if (ios /= 0) exit + end do + + close (unit) + end subroutine write_png_fixture end program test_integration