diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 00000000..0640f684 --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,43 @@ +name: Ruby + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.2' + - '3.3' + - '3.4' + - '4.0' + + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + - name: Run unit and specification tests + run: bundle exec rake test + - name: Check and schedule a project + run: | + bundle exec ruby -Ilib lib/tj3.rb --silent --check-syntax test/TestSuite/Syntax/Correct/Simple.tjp + bundle exec ruby -Ilib lib/tj3.rb --silent --no-reports test/TestSuite/Syntax/Correct/Simple.tjp + bundle exec ruby -Ilib lib/tj3.rb --silent --no-reports --list-reports '.*' test/TestSuite/Syntax/Correct/tutorial.tjp + - name: Generate reports with multiple workers + run: | + mkdir -p "${RUNNER_TEMP}/taskjuggler-reports" + bundle exec ruby -Ilib lib/tj3.rb --silent -c 2 \ + --output-dir "${RUNNER_TEMP}/taskjuggler-reports" \ + test/TestSuite/Syntax/Correct/tutorial.tjp + test "$(find "${RUNNER_TEMP}/taskjuggler-reports" -type f | wc -l)" -eq 20 + - name: Exercise the daemon and web server + run: bundle exec ruby test/web_server_smoke.rb diff --git a/.gitignore b/.gitignore index 4e61ab87..befb585f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ benchmarks/css benchmarks/icons benchmarks/scripts +/Gemfile.lock CHANGELOG doc lib/css diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..fa75df15 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gemspec diff --git a/lib/taskjuggler/BatchProcessor.rb b/lib/taskjuggler/BatchProcessor.rb index 81da1437..fa9b95fa 100644 --- a/lib/taskjuggler/BatchProcessor.rb +++ b/lib/taskjuggler/BatchProcessor.rb @@ -249,7 +249,7 @@ def receiver # Remove the job from the @runningJobs Hash. @runningJobs.delete(pid) # Save the return value. - job.retVal = retVal.exitstatus + job.retVal = retVal if retVal.signaled? cleanPipes(job) # Aborted jobs will probably not send an EOT. So we fastrack diff --git a/lib/taskjuggler/daemon/Daemon.rb b/lib/taskjuggler/daemon/Daemon.rb index d8c06246..193bae62 100644 --- a/lib/taskjuggler/daemon/Daemon.rb +++ b/lib/taskjuggler/daemon/Daemon.rb @@ -69,7 +69,7 @@ def start # We no longer have a controlling terminal, so these are useless. $stdin.reopen('/dev/null') - $stdout.reopen(StringIO.new) + $stdout.reopen('/dev/null', 'a') $stderr.reopen($stdout) info('daemon_pid', diff --git a/lib/taskjuggler/daemon/ReportServlet.rb b/lib/taskjuggler/daemon/ReportServlet.rb index d091bd6c..ab5642d8 100644 --- a/lib/taskjuggler/daemon/ReportServlet.rb +++ b/lib/taskjuggler/daemon/ReportServlet.rb @@ -105,9 +105,13 @@ def generateReport(projectId, reportId, attributes) # text from the report server. This buffer will contain the generated # report as HTML encoded text. They will be send via DRb, so we have to # extend them with DRbUndumped. - stdOut = StringIO.new('') + # + # Note: In Ruby 4.0+, StringIO.new('') unexpectedly creates a read-only + # buffer in the web server context. Using StringIO.new without arguments + # avoids this issue. Root cause not yet identified. + stdOut = StringIO.new stdOut.extend(DRbUndumped) - stdErr = StringIO.new('') + stdErr = StringIO.new stdErr.extend(DRbUndumped) begin @@ -119,6 +123,7 @@ def generateReport(projectId, reportId, attributes) end error('rs_io_connect_failed', "Can't connect IO: #{$!}") + return end # Ask the ReportServer to generate the reports with the provided ID. @@ -172,7 +177,7 @@ def generateWelcomePage(projectId) "Cannot get project list from daemon: #{$!}") end - text = "== Welcome to the TaskJuggler Project Server ==\n----\n" + text = +"== Welcome to the TaskJuggler Project Server ==\n----\n" projects.each do |id| if id == projectId # Show the list of reports for this project. diff --git a/spec/ReportServlet_spec.rb b/spec/ReportServlet_spec.rb new file mode 100644 index 00000000..2a31a251 --- /dev/null +++ b/spec/ReportServlet_spec.rb @@ -0,0 +1,86 @@ +#!/usr/bin/env ruby -w +# frozen_string_literal: true +# encoding: UTF-8 +# +# = ReportServlet_spec.rb -- The TaskJuggler III Project Management Software +# +# Copyright (c) 2026 Enno Richter <2536303+elohmeier@users.noreply.github.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. +# + +require 'taskjuggler/Tj3Config' +require 'taskjuggler/daemon/ReportServlet' + +class TaskJuggler + + describe ReportServlet do + + def response + Class.new do + attr_accessor :body, :status + + def initialize + @headers = {} + end + + def [](key) + @headers[key] + end + + def []=(key, value) + @headers[key] = value + end + end.new + end + + it 'generates a welcome page with projects when string literals are frozen' do + broker = double('broker', :getProjectList => [ 'example' ], + :disconnect => nil) + servlet = ReportServlet.allocate + res = response + servlet.instance_variable_set(:@res, res) + allow(AppConfig).to receive(:appName).and_return('tj3webd') + allow(servlet).to receive(:connectToBroker).and_return(broker) + allow(servlet).to receive(:getProjectName).with('example'). + and_return('Example Project') + + servlet.send(:generateWelcomePage, '') + + res['content-type'].should eq('text/html') + res.body.should match(/Example Project/) + end + + it 'uses writable buffers while generating a report' do + broker = double('broker', :getProject => [ 'project-uri', 'project-key' ], + :disconnect => nil) + projectServer = double('project server', + :getReportServer => [ 'report-uri', 'report-key' ]) + reportServer = double('report server') + allow(reportServer).to receive(:connect) do |_key, stdOut, stdErr, + _stdIn, _silent| + stdOut.write('Generated report') + stdErr.write('') + end + allow(reportServer).to receive(:generateReport).and_return(true) + allow(reportServer).to receive(:disconnect) + allow(reportServer).to receive(:terminate) + allow(DRbObject).to receive(:new). + and_return(projectServer, reportServer) + + servlet = ReportServlet.allocate + res = response + servlet.instance_variable_set(:@res, res) + allow(servlet).to receive(:connectToBroker).and_return(broker) + + servlet.send(:generateReport, 'example', 'report', '') + + res['content-type'].should eq('text/html') + res.body.should eq('Generated report') + end + + end + +end diff --git a/spec/support/DaemonControl.rb b/spec/support/DaemonControl.rb index f88d9d9d..dbd9394b 100644 --- a/spec/support/DaemonControl.rb +++ b/spec/support/DaemonControl.rb @@ -41,7 +41,7 @@ def startDaemon(config = '') $stdout.reopen('stdout.log', 'w') $stderr.reopen('stderr.log', 'w') res = stdIoWrapper do - Tj3Daemon.new.main(%w( --silent )) + Tj3Daemon.new.main(%w( --silent --dont-daemonize )) end raise "Failed to start tj3d: #{res.stdErr}" if res.returnValue != 0 exit! diff --git a/taskjuggler.gemspec b/taskjuggler.gemspec index 3232b86f..e24d4804 100644 --- a/taskjuggler.gemspec +++ b/taskjuggler.gemspec @@ -15,14 +15,14 @@ lib = File.expand_path('../lib', __FILE__) $:.unshift lib unless $:.include?(lib) -# Get software version number from Tj3Config class. -begin - $: << 'lib' - require 'taskjuggler/Tj3Config' - PROJECT_VERSION = AppConfig.version - PROJECT_NAME = AppConfig.softwareName -rescue LoadError - raise "Error: Cannot determine software settings: #{$!}" +# Keep gem metadata loadable before runtime dependencies have been installed. +require_relative 'lib/taskjuggler/version' +PROJECT_VERSION = VERSION +PROJECT_NAME = 'TaskJuggler' + +filesIn = lambda do |directory| + files = (`git ls-files -- #{directory} 2>/dev/null`).split("\n") + files.empty? ? Dir.glob("#{directory}/**/*").select { |f| File.file?(f) } : files end GEM_SPEC = Gem::Specification.new { |s| @@ -45,26 +45,29 @@ management. EOT s.license = 'GPL-2.0-only' s.require_path = 'lib' - s.files = (`git ls-files -- lib`).split("\n") + - (`git ls-files -- data`).split("\n") + - (`git ls-files -- manual`).split("\n") + - (`git ls-files -- examples`).split("\n") + - (`git ls-files -- tasks`).split("\n") + + s.files = filesIn.call('lib') + + filesIn.call('data') + + filesIn.call('manual') + + filesIn.call('examples') + + filesIn.call('tasks') + %w( .gemtest taskjuggler.gemspec Rakefile ) + # Generated files, not contained in Git repository. - %w( data/tjp.vim ) + Dir.glob('manual/html/**/*') + Dir.glob('man/*.1') + Dir.glob('manual/html/**/*') + Dir.glob('man/*.1') s.bindir = 'bin' - s.executables = (`git ls-files -- bin`).split("\n"). + s.executables = filesIn.call('bin'). map { |fn| File.basename(fn) } - s.test_files = (`git ls-files -- test`).split("\n") + - (`git ls-files -- spec`).split("\n") + s.test_files = filesIn.call('test') + filesIn.call('spec') - s.extra_rdoc_files = %w( README.rdoc COPYING CHANGELOG ) + s.extra_rdoc_files = %w( README.rdoc COPYING ) + s.add_dependency('base64', '>= 0.2.0') + s.add_dependency('drb', '>= 2.1.0') s.add_dependency('mail', '~> 2.7', '>= 2.7.1') s.add_dependency('webrick', '~> 1.9', '>= 1.9.1') s.add_runtime_dependency('term-ansicolor', '~> 1.7', '>= 1.7.1') - s.add_development_dependency('rspec', '~> 2.5', '>= 2.5.0') + s.add_development_dependency('rake', '~> 13.0') + s.add_development_dependency('rspec', '~> 3.13') + s.add_development_dependency('test-unit', '~> 3.7') s.platform = Gem::Platform::RUBY - s.required_ruby_version = '>= 2.0.0' + s.required_ruby_version = '>= 3.2.0' } diff --git a/test/TestSuite/Export-Reports/refs/Leave.tjp b/test/TestSuite/Export-Reports/refs/Leave.tjp new file mode 100644 index 00000000..d75ddbfa --- /dev/null +++ b/test/TestSuite/Export-Reports/refs/Leave.tjp @@ -0,0 +1,69 @@ +project prj "Annual Leave" "1.0" 2011-12-19-00:00-+0000 - 2012-12-18-00:00-+0000 { + timezone "UTC" + scenario plan "Plan Scenario" { + active yes + } +} + +projectids prj + +shift s1 "Shift 1" { + workinghours sun off + workinghours mon 9:00 - 17:00 + workinghours tue 9:00 - 17:00 + workinghours wed 9:00 - 17:00 + workinghours thu 9:00 - 17:00 + workinghours fri 9:00 - 17:00 + workinghours sat off +} +resource team "Team" { + resource r1 "R1" + resource r2 "R2" +} +resource r3 "R3" + +task _Task_1 "foo" { + start 2011-12-19-00:00-+0000 + scheduled +} +supplement task _Task_1 { + priority 500 + projectid prj +} +supplement resource team { + workinghours sun off + workinghours mon 9:00 - 17:00 + workinghours tue 9:00 - 17:00 + workinghours wed 9:00 - 17:00 + workinghours thu 9:00 - 17:00 + workinghours fri 9:00 - 17:00 + workinghours sat off +} +supplement resource r1 { + workinghours sun off + workinghours mon 9:00 - 17:00 + workinghours tue 9:00 - 17:00 + workinghours wed 9:00 - 17:00 + workinghours thu 9:00 - 17:00 + workinghours fri 9:00 - 17:00 + workinghours sat off +} +supplement resource r2 { + workinghours sun off + workinghours mon 9:00 - 17:00 + workinghours tue 9:00 - 17:00 + workinghours wed 9:00 - 17:00 + workinghours thu 9:00 - 17:00 + workinghours fri 9:00 - 17:00 + workinghours sat off +} +supplement resource r3 { + shifts s1 2011-12-19-00:00-+0000 - 2012-01-09-00:00-+0000 + workinghours sun off + workinghours mon 9:00 - 17:00 + workinghours tue 9:00 - 17:00 + workinghours wed 9:00 - 17:00 + workinghours thu 9:00 - 17:00 + workinghours fri 9:00 - 17:00 + workinghours sat off +} diff --git a/test/TestSuite/Export-Reports/refs/ListAttributes.tjp b/test/TestSuite/Export-Reports/refs/ListAttributes.tjp new file mode 100644 index 00000000..d8664eaa --- /dev/null +++ b/test/TestSuite/Export-Reports/refs/ListAttributes.tjp @@ -0,0 +1,66 @@ +project prj "List attributes" "1.0" 2014-04-06-00:00-+0000 - 2014-05-06-10:00-+0000 { + timezone "UTC" + scenario s1 "S1" { + scenario s2 "S2" { + active yes + } + active yes + } +} + +flags f1, f2, f3 + +projectids prj + + +task _Task_1 "T1" { + start 2014-04-06-00:00-+0000 + scheduled +} +task _Task_2 "T2" { + task _Task_3 "T3" { + start 2014-04-06-00:00-+0000 + scheduled + } +} +task _Task_4 "T4" { + start 2014-04-06-00:00-+0000 + scheduled +} +task _Task_5 "T5" { + task _Task_6 "T6" { + start 2014-04-06-00:00-+0000 + scheduled + } +} +supplement task _Task_1 { + flags f1, f2, f3 + priority 500 + projectid prj +} +supplement task _Task_2 { + flags f1 + priority 500 + projectid prj +} +supplement task _Task_2._Task_3 { + flags f1, f2 + priority 500 + projectid prj +} +supplement task _Task_4 { + flags f1 + s2:flags f1, f2 + priority 500 + projectid prj +} +supplement task _Task_5 { + flags f1 + priority 500 + projectid prj +} +supplement task _Task_5._Task_6 { + flags f2 + priority 500 + projectid prj +} diff --git a/test/TestSuite/Export-Reports/refs/Macro-4.tjp b/test/TestSuite/Export-Reports/refs/Macro-4.tjp new file mode 100644 index 00000000..5a1a7af0 --- /dev/null +++ b/test/TestSuite/Export-Reports/refs/Macro-4.tjp @@ -0,0 +1,18 @@ +project prj "Test" "1.0" 2012-08-17-00:00-+0000 - 2012-09-16-10:00-+0000 { + timezone "UTC" + scenario plan "Plan Scenario" { + active yes + } +} + +projectids prj + + +task t "T" { + start 2012-08-17-00:00-+0000 + scheduled +} +supplement task t { + priority 500 + projectid prj +} diff --git a/test/TestSuite/Export-Reports/refs/Timezone2.tjp b/test/TestSuite/Export-Reports/refs/Timezone2.tjp new file mode 100644 index 00000000..20c6ad4d --- /dev/null +++ b/test/TestSuite/Export-Reports/refs/Timezone2.tjp @@ -0,0 +1,18 @@ +project tz "Timezone" "1.0" 2005-06-05-11:00-+0000 - 2005-06-06-11:00-+0000 { + timezone "Pacific/Auckland" + scenario plan "Plan Scenario" { + active yes + } +} + +projectids tz + + +task item "Project" { + start 2005-06-06-00:00-+0000 + scheduled +} +supplement task item { + priority 500 + projectid tz +} diff --git a/test/TestSuite/Export-Reports/refs/TraceReport.tjp b/test/TestSuite/Export-Reports/refs/TraceReport.tjp new file mode 100644 index 00000000..3acb8dd5 --- /dev/null +++ b/test/TestSuite/Export-Reports/refs/TraceReport.tjp @@ -0,0 +1,34 @@ +project prj "Trace Reports" "1.0" 2012-01-14-00:00-+0000 - 2012-03-14-20:00-+0000 { + timezone "UTC" + scenario plan "Plan Scenario" { + active yes + } +} + +projectids prj + + +task _Task_1 "Foo" { + start 2012-01-14-00:00-+0000 + scheduled +} +task _Task_2 "Bar" { + start 2012-01-14-00:00-+0000 + scheduled +} +task _Task_3 "FooBar" { + start 2012-01-14-00:00-+0000 + scheduled +} +supplement task _Task_1 { + priority 500 + projectid prj +} +supplement task _Task_2 { + priority 500 + projectid prj +} +supplement task _Task_3 { + priority 500 + projectid prj +} diff --git a/test/test_BatchProcessor.rb b/test/test_BatchProcessor.rb index 3c2184c4..c5e1c1dc 100644 --- a/test/test_BatchProcessor.rb +++ b/test/test_BatchProcessor.rb @@ -84,7 +84,8 @@ def runJob(n, &block) end def postprocess(job) - assert_equal(job.retVal, job.jobId, 'PID mismatch') + assert_kind_of(Process::Status, job.retVal) + assert_equal(job.jobId, job.retVal.exitstatus, 'PID mismatch') assert_equal("job #{job.jobId}", job.tag) text = <<"EOT" Job #{job.jobId} started @@ -99,4 +100,3 @@ def postprocess(job) assert_equal(text, job.stderr, "STDERR mismatch #{job.stderr}") end end - diff --git a/test/web_server_smoke.rb b/test/web_server_smoke.rb new file mode 100644 index 00000000..7bdb3c55 --- /dev/null +++ b/test/web_server_smoke.rb @@ -0,0 +1,128 @@ +#!/usr/bin/env ruby -w +# frozen_string_literal: true +# encoding: UTF-8 +# +# = web_server_smoke.rb -- The TaskJuggler III Project Management Software +# +# Copyright (c) 2026 Enno Richter <2536303+elohmeier@users.noreply.github.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. +# + +require 'net/http' +require 'rbconfig' +require 'fileutils' +require 'socket' +require 'timeout' +require 'tmpdir' + +def freePort + server = TCPServer.new('127.0.0.1', 0) + port = server.addr[1] + server.close + port +end + +def waitForPort(port) + Timeout.timeout(15) do + loop do + socket = TCPSocket.new('127.0.0.1', port) + socket.close + return + rescue Errno::ECONNREFUSED + sleep 0.1 + end + end +end + +def stopProcess(pid) + return unless pid + + begin + Process.kill('TERM', pid) + rescue Errno::ESRCH + end + + begin + Timeout.timeout(5) { Process.wait(pid) } + rescue Errno::ECHILD + rescue Timeout::Error + begin + Process.kill('KILL', pid) + Process.wait(pid) + rescue Errno::ESRCH, Errno::ECHILD + end + end +end + +sourceDir = File.expand_path('..', __dir__) +sourceProjectFile = File.join(sourceDir, + 'test/TestSuite/Syntax/Correct/textreport.tjp') +daemonPort = freePort +webPort = freePort + +Dir.mktmpdir('taskjuggler-web-smoke-') do |runDir| + configFile = File.join(runDir, 'taskjuggler.rc') + daemonLog = File.join(runDir, 'daemon.log') + projectFile = File.join(runDir, 'textreport.tjp') + webLog = File.join(runDir, 'web.log') + File.write(configFile, "_global:\n authKey: ruby-compatibility-smoke\n") + FileUtils.cp(sourceProjectFile, projectFile) + + daemonPid = nil + webPid = nil + begin + daemonPid = Process.spawn(RbConfig.ruby, "-I#{sourceDir}/lib", + File.join(sourceDir, 'lib/tj3d.rb'), + '--silent', '--no-color', '--dont-daemonize', + '--config', configFile, + '--port', daemonPort.to_s, + projectFile, + :chdir => runDir, + :out => daemonLog, :err => [ :child, :out ]) + waitForPort(daemonPort) + + webPid = Process.spawn(RbConfig.ruby, "-I#{sourceDir}/lib", + File.join(sourceDir, 'lib/tj3webd.rb'), + '--silent', '--no-color', '--dont-daemonize', + '--config', configFile, + '--port', daemonPort.to_s, + '--webserver-port', webPort.to_s, + :chdir => runDir, + :out => webLog, :err => [ :child, :out ]) + waitForPort(webPort) + + base = URI("http://127.0.0.1:#{webPort}") + welcome = Net::HTTP.get_response(base + '/taskjuggler') + unless welcome.code == '200' + raise "Welcome page returned HTTP #{welcome.code}: #{welcome.body}" + end + + projectPath = welcome.body[/href="([^"]*project=[^"]*)"/, 1] + raise 'Welcome page has no project link' unless projectPath + + projectPage = Net::HTTP.get_response(base + projectPath.gsub('&', '&')) + unless projectPage.code == '200' + raise "Project page returned HTTP #{projectPage.code}: #{projectPage.body}" + end + + reportPath = projectPage.body[/href="([^"]*report=[^"]*)"/, 1] + raise 'Project page has no report link' unless reportPath + + reportPage = Net::HTTP.get_response(base + reportPath.gsub('&', '&')) + unless reportPage.code == '200' && reportPage.body.include?('