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