add nextflow d30e48d

This commit is contained in:
2026-04-29 23:01:54 +02:00
parent d0b12d668d
commit 97cc9058d3
2840 changed files with 730250 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import java.nio.file.Files
import java.nio.file.Paths
import nextflow.SysEnv
import nextflow.exception.AbortOperationException
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class CacheManagerTest extends Specification {
def setupSpec() {
SysEnv.push([:])
}
def cleanupSpec() {
SysEnv.pop()
}
def 'should init empty files' () {
when:
new CacheManager([:])
then:
thrown(AbortOperationException)
}
def 'should upload cache files' () {
given:
def folder = Files.createTempDirectory('test')
def remote = folder.resolve('remote'); remote.mkdir()
def local = folder.resolve('local'); local.mkdir()
def outFile = local.resolve('nf-out.txt'); outFile.text = 'out file'
def logFile = local.resolve('nf-log.txt'); logFile.text = 'log file'
def tmlFile = local.resolve('nf-tml.txt'); tmlFile.text = 'tml file'
def cfgFile = local.resolve('tw-config.txt'); cfgFile.text = 'config file'
def repFile = local.resolve('tw-report.txt'); repFile.text = 'report file'
and:
def uuid = UUID.randomUUID().toString()
and:
def ENV = [
NXF_UUID:uuid,
NXF_WORK: remote.toString(),
NXF_OUT_FILE: outFile.toString(),
NXF_LOG_FILE: logFile.toString(),
NXF_TML_FILE: tmlFile.toString(),
TOWER_CONFIG_FILE: cfgFile.toString(),
TOWER_REPORTS_FILE: repFile.toString(),
]
when:
def tower = new CacheManager(ENV)
then:
tower.sessionUuid == uuid
tower.localCachePath == Paths.get(".nextflow/cache/$uuid")
tower.localOutFile == outFile
tower.localLogFile == logFile
tower.localTimelineFile == tmlFile
tower.localTowerConfig == cfgFile
tower.localTowerReports == repFile
and:
tower.remoteWorkDir == remote
and:
tower.remoteCachePath == remote.resolve(".nextflow/cache/$uuid")
tower.remoteOutFile == remote.resolve( outFile.name )
tower.remoteLogFile == remote.resolve( logFile.name )
tower.remoteTimelineFile == remote.resolve( tmlFile.name )
tower.remoteTowerConfig == remote.resolve( cfgFile.name )
tower.remoteTowerReports == remote.resolve( repFile.name )
when:
// create local cache fake data
tower.localCachePath = local.resolve(".nextflow/cache/$uuid");
tower.localCachePath.mkdirs()
tower.localCachePath.resolve('index-foo').text = 'index foo'
tower.localCachePath.resolve('db').mkdir()
tower.localCachePath.resolve('db/xxx').text = 'data xxx'
tower.localCachePath.resolve('db/yyy').text = 'data yyy'
and:
tower.saveCacheFiles()
then:
tower.remoteCachePath.resolve('index-foo').text == 'index foo'
tower.remoteCachePath.resolve('db/xxx').text == 'data xxx'
tower.remoteCachePath.resolve('db/yyy').text == 'data yyy'
and:
tower.remoteOutFile.text == outFile.text
tower.remoteLogFile.text == logFile.text
tower.remoteTimelineFile.text == tmlFile.text
tower.remoteTowerConfig.text == cfgFile.text
tower.remoteTowerReports.text == repFile.text
// simulate a 2nd run with different data
when:
tower.localCachePath.deleteDir()
tower.localCachePath.mkdirs()
tower.localCachePath.resolve('index-bar').text = 'index bar'
tower.localCachePath.resolve('db').mkdir()
tower.localCachePath.resolve('db/alpha').text = 'data alpha'
tower.localCachePath.resolve('db/delta').text = 'data delta'
and:
tower.saveCacheFiles()
then:
tower.remoteCachePath.resolve('index-bar').text == 'index bar'
tower.remoteCachePath.resolve('db/alpha').text == 'data alpha'
tower.remoteCachePath.resolve('db/delta').text == 'data delta'
and:
!tower.remoteCachePath.resolve('index-foo').exists()
!tower.remoteCachePath.resolve('db/xxx').exists()
!tower.remoteCachePath.resolve('db/yyy').exists()
and:
tower.remoteOutFile.text == outFile.text
tower.remoteLogFile.text == logFile.text
tower.remoteTimelineFile.text == tmlFile.text
tower.remoteTowerConfig.text == cfgFile.text
tower.remoteTowerReports.text == repFile.text
cleanup:
folder?.deleteDir()
}
def 'should upload log files even when local cache path does not exist' () {
given:
def folder = Files.createTempDirectory('test')
def remote = folder.resolve('remote'); remote.mkdir()
def local = folder.resolve('local'); local.mkdir()
def outFile = local.resolve('nf-out.txt'); outFile.text = 'out file'
def logFile = local.resolve('nf-log.txt'); logFile.text = 'log file'
def tmlFile = local.resolve('nf-tml.txt'); tmlFile.text = 'tml file'
def cfgFile = local.resolve('tw-config.txt'); cfgFile.text = 'config file'
def repFile = local.resolve('tw-report.txt'); repFile.text = 'report file'
and:
def uuid = UUID.randomUUID().toString()
and:
def ENV = [
NXF_UUID: uuid,
NXF_WORK: remote.toString(),
NXF_OUT_FILE: outFile.toString(),
NXF_LOG_FILE: logFile.toString(),
NXF_TML_FILE: tmlFile.toString(),
TOWER_CONFIG_FILE: cfgFile.toString(),
TOWER_REPORTS_FILE: repFile.toString(),
]
when:
def tower = new CacheManager(ENV)
// do NOT create localCachePath — simulates K8s where cache may not exist locally
tower.saveCacheFiles()
then:
// metadata cache is not copied
!tower.remoteCachePath.exists()
and:
// log files are still copied
tower.remoteOutFile.text == outFile.text
tower.remoteLogFile.text == logFile.text
tower.remoteTimelineFile.text == tmlFile.text
tower.remoteTowerConfig.text == cfgFile.text
tower.remoteTowerReports.text == repFile.text
cleanup:
folder?.deleteDir()
}
def 'should download cache files' () {
given:
def uuid = UUID.randomUUID().toString()
def folder = Files.createTempDirectory('test')
def local = folder.resolve('local'); local.mkdir()
def outFile = local.resolve('nf-out.txt');
def logFile = local.resolve('nf-log.txt')
def tmlFile = local.resolve('nf-tml.txt')
def cfgFile = local.resolve('tw-config.txt')
def repFile = local.resolve('tw-report.txt')
and:
def remote = folder.resolve('remote'); remote.mkdir()
remote.resolve('nf-out.txt').text = 'the out file'
remote.resolve('nf-log.txt').text = 'the log file'
remote.resolve('nf-tml.txt').text = 'the timeline file'
remote.resolve('nf-config.txt').text = 'the config file'
remote.resolve('nf-report.txt').text = 'the report file'
and:
remote.resolve(".nextflow/cache/$uuid").mkdirs()
remote.resolve(".nextflow/cache/$uuid").resolve('index-bar').text = 'index bar'
remote.resolve(".nextflow/cache/$uuid").resolve('db').mkdirs()
remote.resolve(".nextflow/cache/$uuid").resolve('db/alpha').text = 'data alpha'
remote.resolve(".nextflow/cache/$uuid").resolve('db/delta').text = 'data delta'
and:
def tower = new CacheManager([NXF_UUID: uuid, NXF_WORK: remote.toString()])
when:
tower.restoreCacheFiles()
then:
tower.localCachePath.resolve('index-bar').text == 'index bar'
tower.localCachePath.resolve('db/alpha').text == 'data alpha'
tower.localCachePath.resolve('db/delta').text == 'data delta'
cleanup:
folder?.deleteDir()
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import nextflow.Session
import nextflow.SysEnv
import nextflow.util.Duration
import spock.lang.Specification
import test.TestHelper
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class LogsCheckpointTest extends Specification {
def 'should configure default delay' () {
given:
def session = Mock(Session) {
getWorkDir() >> TestHelper.createInMemTempDir()
getConfig() >> [:]
}
and:
def checkpoint = new LogsCheckpoint()
when:
checkpoint.onFlowCreate(session)
then:
checkpoint.@interval == Duration.of('90s')
}
def 'should configure delay via env var' () {
given:
SysEnv.push(TOWER_LOGS_CHECKPOINT_INTERVAL: '200s')
def session = Mock(Session) {
getWorkDir() >> TestHelper.createInMemTempDir()
getConfig() >> [:]
}
and:
def checkpoint = new LogsCheckpoint()
when:
checkpoint.onFlowCreate(session)
then:
checkpoint.@interval == Duration.of('200s')
cleanup:
SysEnv.pop()
}
def 'should configure delay via config file' () {
given:
SysEnv.push(NXF_WORK: '/some/path', TOWER_LOGS_CHECKPOINT_INTERVAL: '200s')
def session = Mock(Session) {
getConfig()>>[tower:[logs:[checkpoint:[interval: '500s']]]]
getWorkDir() >> TestHelper.createInMemTempDir()
}
and:
def checkpoint = new LogsCheckpoint()
when:
checkpoint.onFlowCreate(session)
then:
checkpoint.@interval == Duration.of('500s')
cleanup:
SysEnv.pop()
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import java.nio.file.Files
import nextflow.Session
import nextflow.exception.AbortOperationException
import spock.lang.Specification
import test.TestHelper
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class LogsHandlerTest extends Specification {
def 'should init empty files' () {
when:
new LogsHandler(Mock(Session), [:])
then:
thrown(AbortOperationException)
}
def 'should upload cache files' () {
given:
def folder = Files.createTempDirectory('test')
def remote = TestHelper.createInMemTempDir()
def local = folder.resolve('local'); local.mkdir()
def outFile = local.resolve('nf-out.txt'); outFile.text = 'out file'
def logFile = local.resolve('nf-log.txt'); logFile.text = 'log file'
def tmlFile = local.resolve('nf-tml.txt'); tmlFile.text = 'tml file'
def cfgFile = local.resolve('tw-config.txt'); cfgFile.text = 'config file'
def repFile = local.resolve('tw-report.txt'); repFile.text = 'report file'
and:
def uuid = UUID.randomUUID().toString()
and:
def session = Mock(Session) {getWorkDir() >> remote }
def ENV = [
NXF_UUID:uuid,
NXF_OUT_FILE: outFile.toString(),
NXF_LOG_FILE: logFile.toString(),
NXF_TML_FILE: tmlFile.toString(),
TOWER_CONFIG_FILE: cfgFile.toString(),
TOWER_REPORTS_FILE: repFile.toString(),
]
when:
def tower = new LogsHandler(session, ENV)
then:
tower.localOutFile == outFile
tower.localLogFile == logFile
tower.localTimelineFile == tmlFile
tower.localTowerConfig == cfgFile
tower.localTowerReports == repFile
and:
tower.remoteWorkDir == remote
and:
tower.remoteOutFile == remote.resolve( outFile.name )
tower.remoteLogFile == remote.resolve( logFile.name )
tower.remoteTimelineFile == remote.resolve( tmlFile.name )
tower.remoteTowerConfig == remote.resolve( cfgFile.name )
tower.remoteTowerReports == remote.resolve( repFile.name )
when:
// create local cache fake data
tower.saveFiles()
then:
tower.remoteOutFile.text == outFile.text
tower.remoteLogFile.text == logFile.text
tower.remoteTimelineFile.text == tmlFile.text
tower.remoteTowerConfig.text == cfgFile.text
tower.remoteTowerReports.text == repFile.text
cleanup:
folder?.deleteDir()
}
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import java.net.http.HttpResponse
import java.time.Instant
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.client.WireMock
import io.seqera.http.HxClient
import nextflow.exception.AbortRunException
import nextflow.util.Duration
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class TowerClientTest extends Specification {
protected boolean aroundNow(value) {
def now = Instant.now().toEpochMilli()
value > now-1_000 && value <= now
}
def 'should parse response' () {
given:
def tower = new TowerClient()
when:
def resp = new TowerClient.Response(200, '{"status":"OK", "workflowId":"12345", "watchUrl": "http://foo.com/watch/12345"}')
def result = tower.parseTowerResponse(resp)
then:
result.workflowId == '12345'
result.watchUrl == 'http://foo.com/watch/12345'
when:
resp = new TowerClient.Response(500, '{"status":"OK", "workflowId":"12345"}')
tower.parseTowerResponse(resp)
then:
thrown(Exception)
}
def 'should validate URL' () {
given:
def observer = new TowerClient()
expect:
observer.checkUrl('http://localhost') == 'http://localhost'
observer.checkUrl('http://google.com') == 'http://google.com'
observer.checkUrl('https://google.com') == 'https://google.com'
observer.checkUrl('http://google.com:8080') == 'http://google.com:8080'
observer.checkUrl('http://google.com:8080/') == 'http://google.com:8080'
observer.checkUrl('http://google.com:8080/foo/bar') == 'http://google.com:8080/foo/bar'
observer.checkUrl('http://google.com:8080/foo/bar/') == 'http://google.com:8080/foo/bar'
observer.checkUrl('http://google.com:8080/foo/bar///') == 'http://google.com:8080/foo/bar'
when:
observer.checkUrl('ftp://localhost')
then:
def e = thrown(IllegalArgumentException)
e.message == 'Only http and https are supported -- The given URL was: ftp://localhost'
}
def 'should get watch url' () {
given:
def observer = new TowerClient()
expect:
observer.getHostUrl(STR) == EXPECTED
where:
STR | EXPECTED
'http://foo.com' | 'http://foo.com'
'http://foo.com:800/' | 'http://foo.com:800'
'https://foo.com:800/' | 'https://foo.com:800'
'http://foo.com:8000/this/that' | 'http://foo.com:8000'
}
def 'should get access token' () {
when:
def config = new TowerConfig([accessToken: 'abc'], [TOWER_ACCESS_TOKEN: 'xyz'])
def client = new TowerClient(config)
then:
// the token in the config overrides the one in the env
client.getAccessToken() == 'abc'
when:
config = new TowerConfig([accessToken: 'abc'], [TOWER_ACCESS_TOKEN: 'xyz', TOWER_WORKFLOW_ID: '111222333'])
client = new TowerClient(config)
then:
// the token from the env is taken because is a tower launch aka TOWER_WORKFLOW_ID is set
client.getAccessToken() == 'xyz'
when:
config = new TowerConfig([:], [TOWER_ACCESS_TOKEN: 'xyz'])
client = new TowerClient(config)
then:
client.getAccessToken() == 'xyz'
when:
def c = new TowerClient()
c.getAccessToken()
then:
thrown(AbortRunException)
}
def 'should set the auth token' () {
given:
def http = Mock(HxClient.Builder)
def client = new TowerClient()
and:
def SIMPLE = '4ffbf1009ebabea77db3d72efefa836dfbb71271'
def BEARER = 'eyJ0aWQiOiA1fS5jZmM1YjVhOThjZjM2MTk1NjBjZWU1YmMwODUxYzA1ZjkzMDdmN2Iz'
when:
client.setupClientAuth(http, SIMPLE)
then:
1 * http.basicAuth('@token:' + SIMPLE) >> http
when:
client.setupClientAuth(http, SIMPLE)
then:
1 * http.basicAuth('@token:' + SIMPLE) >> http
when:
client.setupClientAuth(http, BEARER)
then:
1 * http.bearerToken(BEARER) >> http
1 * http.refreshToken(_) >> http
1 * http.refreshTokenUrl(_) >> http
}
def 'should get trace endpoint' () {
given:
def client = new TowerClient()
client.@endpoint = TowerClient.DEF_ENDPOINT_URL
expect:
client.getUrlTraceCreate(null) == 'https://api.cloud.seqera.io/trace/create'
client.getUrlTraceBegin(null, '12345') == 'https://api.cloud.seqera.io/trace/12345/begin'
client.getUrlTraceProgress(null, '12345') == 'https://api.cloud.seqera.io/trace/12345/progress'
client.getUrlTraceHeartbeat(null, '12345') == 'https://api.cloud.seqera.io/trace/12345/heartbeat'
client.getUrlTraceComplete(null, '12345') == 'https://api.cloud.seqera.io/trace/12345/complete'
}
def 'should get trace endpoint with workspace' () {
given:
def client = new TowerClient()
client.@endpoint = TowerClient.DEF_ENDPOINT_URL
expect:
client.getUrlTraceCreate('300') == 'https://api.cloud.seqera.io/trace/create?workspaceId=300'
client.getUrlTraceBegin('300', '12345') == 'https://api.cloud.seqera.io/trace/12345/begin?workspaceId=300'
client.getUrlTraceProgress('300', '12345') == 'https://api.cloud.seqera.io/trace/12345/progress?workspaceId=300'
client.getUrlTraceHeartbeat('300', '12345') == 'https://api.cloud.seqera.io/trace/12345/heartbeat?workspaceId=300'
client.getUrlTraceComplete('300', '12345') == 'https://api.cloud.seqera.io/trace/12345/complete?workspaceId=300'
}
def 'should load schema col len' () {
given:
def tower = new TowerClient()
when:
def schema = tower.loadSchema()
then:
schema.get('workflow.start') == null
schema.get('workflow.profile') == 100
schema.get('workflow.projectDir') == 255
}
def 'should handle HTTP request with content'() {
given: 'a TowerClient'
def tower = new TowerClient()
def content = '{"test": "data"}'
def request = tower.makeRequest('http://example.com/test', content, 'POST')
expect: 'the request should be created with the content'
request != null
request.method() == 'POST'
request.uri().toString() == 'http://example.com/test'
}
def 'should send http message' () {
given:
def client = Mock(HxClient)
def tower = new TowerClient()
tower.@httpClient = client
when:
def resp = tower.sendHttpMessage('http://foo.com', [foo: 'bar'], 'POST')
then:
1 * client.sendAsString(_) >> Mock(HttpResponse) { statusCode() >> 200; body() >> '{}' }
and:
!resp.error
resp.code == 200
}
def 'should return error response on http request timeout' () {
given: 'a WireMock server that hangs for 5 seconds'
def wireMock = new WireMockServer(0)
wireMock.start()
wireMock.stubFor(
WireMock.post(WireMock.anyUrl())
.willReturn(WireMock.aResponse()
.withFixedDelay(5_000)
.withStatus(200)
.withBody('{}'))
)
and: 'a TowerClient whose requests carry a 200ms timeout'
TowerConfig config = Mock(TowerConfig) {
getHttpReadTimeout() >> Duration.of('200 ms')
getHttpConnectTimeout() >> Duration.of('5 s')
getEndpoint() >> wireMock.baseUrl()
getAccessToken() >> 'token'
}
TowerClient client = new TowerClient(config)
when:
def response = client.sendHttpMessage("${wireMock.baseUrl()}/trace/create", [runName: 'test'], 'POST')
then: 'a timeout produces an error response with code 0'
response.code == 0
response.message.contains('Unable to connect')
cleanup:
wireMock.stop()
}
def 'should build URL without query params'() {
given:
def client = new TowerClient()
client.@endpoint = 'https://api.cloud.seqera.io'
when:
def url = client.buildUrl( '/workflow/launch', [:])
then:
url == 'https://api.cloud.seqera.io/workflow/launch'
}
def 'should build URL with query params'() {
given:
def client = new TowerClient()
client.@endpoint = 'https://api.cloud.seqera.io'
when:
def url = client.buildUrl( '/workflow/launch', [workspaceId: '12345'])
then:
url.contains('https://api.cloud.seqera.io/workflow/launch?')
url.contains('workspaceId=12345')
}
def 'should URL encode query params'() {
given:
def client = new TowerClient()
client.@endpoint = 'https://api.cloud.seqera.io'
when:
def url = client.buildUrl( '/workflow', [name: 'test workflow'])
then:
url.contains('name=test+workflow')
}
def 'should send AbortRunException in selected client calls'() {
given:
def client = Spy(new TowerClient(new TowerConfig([:], [TOWER_ACCESS_TOKEN: 'token']))){
sendHttpMessage(_,_,_) >> new TowerClient.Response(401)
}
when:
client.traceCreate([:], '1234')
then:
thrown(AbortRunException)
when:
client.traceBegin([:], '1234', '5678')
then:
thrown(AbortRunException)
when:
client.traceProgress([:], '1234', '5678')
then:
thrown(AbortRunException)
when:
client.traceComplete([:], '1234', '5678')
then:
notThrown(AbortRunException)
when:
client.traceHeartbeat([:], '1234', '5678')
then:
notThrown(AbortRunException)
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import nextflow.util.Duration
import spock.lang.Specification
/**
* Unit tests for TowerConfig
*/
class TowerConfigTest extends Specification {
def 'should use default endpoint when not specified'() {
when:
def config = new TowerConfig([:], [TOWER_API_ENDPOINT: 'https://example.com'])
then:
config.endpoint == 'https://example.com'
when:
config = new TowerConfig([:], [:])
then:
config.endpoint == 'https://api.cloud.seqera.io'
}
def 'should use default timeout values when not specified'() {
when:
def config = new TowerConfig([:], [:])
then:
config.httpConnectTimeout == Duration.of('60s')
config.httpReadTimeout == Duration.of('60s')
}
def 'should use provided connect timeout when specified'() {
when:
def config = new TowerConfig([httpConnectTimeout: Duration.of('30s')], [:])
then:
config.httpConnectTimeout == Duration.of('30s')
config.httpReadTimeout == Duration.of('60s')
}
def 'should use provided read timeout when specified'() {
when:
def config = new TowerConfig([httpReadTimeout: Duration.of('120s')], [:])
then:
config.httpConnectTimeout == Duration.of('60s')
config.httpReadTimeout == Duration.of('120s')
}
def 'should parse timeout from string value'() {
when:
def config = new TowerConfig([httpConnectTimeout: '5s', httpReadTimeout: '2m'], [:])
then:
config.httpConnectTimeout == Duration.of('5s')
config.httpReadTimeout == Duration.of('2m')
}
}

View File

@@ -0,0 +1,138 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import nextflow.Session
import spock.lang.Specification
import spock.lang.Unroll
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class TowerFactoryTest extends Specification {
def 'should create a tower observer' () {
given:
def factory = new TowerFactory(env: [TOWER_ACCESS_TOKEN: '123'])
when:
def session = Mock(Session) { getConfig() >> [tower: [enabled: true]] }
def observer = factory.create(session)[0] as TowerObserver
then:
observer.@client.endpoint == TowerClient.DEF_ENDPOINT_URL
when:
session = Mock(Session) { getConfig() >> [tower: [enabled: true, endpoint:'http://foo.com/api', accessToken: 'xyz']] }
observer = factory.create(session)[0] as TowerObserver
then:
observer.@client.endpoint == 'http://foo.com/api'
}
def 'should not create a tower observer' () {
given:
def session = Mock(Session)
def factory = new TowerFactory()
when:
def result = factory.create(session)
then:
session.getConfig() >> [:]
then:
result == []
}
def 'should create with workspace id'() {
//
// the workspace id is taken from the env
//
when:
def session = Mock(Session) { getConfig() >> [tower: [enabled: true, accessToken: 'xyz']] }
def factory = new TowerFactory(env: [TOWER_WORKSPACE_ID: '100'])
def observer = (TowerObserver) factory.create(session)[0]
then:
observer.getWorkspaceId() == '100'
//
// the workspace id is taken from the config
//
when:
session = Mock(Session) { getConfig() >> [tower: [enabled: true, workspaceId: '200', accessToken: 'xyz']] }
factory = new TowerFactory(env: [:])
observer = (TowerObserver) factory.create(session)[0]
then:
observer.getWorkspaceId() == '200'
//
// the workspace id is set both in the config and the env
// the config has the priority
//
when:
session = Mock(Session) { getConfig() >> [tower: [enabled: true, workspaceId: '200', accessToken: 'xyz']] }
factory = new TowerFactory(env: [TOWER_WORKSPACE_ID: '100'])
observer = (TowerObserver) factory.create(session)[0]
then:
observer.getWorkspaceId() == '200'
//
// when TOWER_WORKFLOW_ID is set is a tower launch
// then the workspace id is only taken from the env
//
when:
session = Mock(Session) { getConfig() >> [tower: [enabled: true, workspaceId: '200', accessToken: 'xyz']] }
factory = new TowerFactory(env: [TOWER_WORKSPACE_ID: '100', TOWER_WORKFLOW_ID: '111222333', TOWER_ACCESS_TOKEN: 'xyz'])
observer = (TowerObserver) factory.create(session)[0]
then:
observer.getWorkspaceId() == '100'
//
// when enabled is false but `TOWER_WORKFLOW_ID` is provided
// then the observer should be created
//
when:
session = Mock(Session) { getConfig() >> [tower: [enabled: false]]}
factory = new TowerFactory(env: [TOWER_WORKSPACE_ID: '100', TOWER_WORKFLOW_ID: '111222333', TOWER_ACCESS_TOKEN: 'xyz'])
observer = (TowerObserver) factory.create(session)[0]
then:
observer.getWorkspaceId() == '100'
}
@Unroll
def 'should create tower http auth provider' () {
given:
def factory = new TowerFactory()
and:
def provider = factory.provider('https://tower.nf', 'xyz123')
and:
def conn = Spy(HttpURLConnection) {
getURL() >> new URL(URL_STR)
}
expect:
provider.authorize(conn) == EXPECTED
and:
conn.getRequestProperty('Authorization') == AUTH
where:
URL_STR | EXPECTED | AUTH
'http://foo.com' | false | null
'https://tower.nf/' | true | 'Bearer xyz123'
'https://tower.nf/this/that' | true | 'Bearer xyz123'
'HTTPS://TOWER.NF/THIS/THAT' | true | 'Bearer xyz123'
}
}

View File

@@ -0,0 +1,576 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import static com.github.tomakehurst.wiremock.client.WireMock.*
import java.time.Instant
import java.time.temporal.ChronoUnit
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.client.WireMock
import com.github.tomakehurst.wiremock.stubbing.Scenario
import com.google.gson.GsonBuilder
import io.seqera.tower.plugin.exception.UnauthorizedException
import nextflow.Global
import nextflow.Session
import nextflow.SysEnv
import nextflow.exception.AbortRunException
import nextflow.script.WorkflowMetadata
import nextflow.serde.gson.InstantAdapter
import spock.lang.Shared
import spock.lang.Specification
/**
* Test cases for the TowerFusionEnv class.
*
* @author Alberto Miranda <alberto.miranda@seqera.io>
*/
class TowerFusionEnvTest extends Specification {
@Shared
WireMockServer wireMockServer
def setupSpec() {
wireMockServer = new WireMockServer(0)
wireMockServer.start()
}
def cleanupSpec() {
wireMockServer.stop()
}
def setup() {
wireMockServer.resetAll()
SysEnv.push([:]) // <-- ensure the system host env does not interfere
}
def cleanup() {
SysEnv.pop() // <-- restore the system host env
}
static String toJson(Object obj) {
new GsonBuilder()
.registerTypeAdapter(Instant, new InstantAdapter())
.create()
.toJson(obj)
}
def 'should return the endpoint from the config'() {
given: 'a session'
SysEnv.push(['TOWER_API_ENDPOINT': 'https://tower.nf', 'TOWER_ACCESS_TOKEN': 'abc123'])
Global.session = Mock(Session) {
config >> [
tower: [
endpoint: 'https://tower.nf'
]
]
}
when: 'the provider is created'
def provider = new TowerFusionToken()
then: 'the endpoint has the expected value'
provider.endpoint == 'https://tower.nf'
cleanup:
SysEnv.pop()
}
def 'should return the endpoint from the environment'() {
setup:
SysEnv.push(['TOWER_API_ENDPOINT': 'https://tower.nf', 'TOWER_ACCESS_TOKEN': 'abc123'])
Global.session = Mock(Session) {
config >> [:]
}
when: 'the provider is created'
def provider = new TowerFusionToken()
then: 'the endpoint has the expected value'
provider.endpoint == 'https://tower.nf'
cleanup:
SysEnv.pop()
}
def 'should return the default endpoint'() {
SysEnv.push(['TOWER_ACCESS_TOKEN': 'abc123'])
when: 'session config is empty'
Global.session = Mock(Session) {
config >> [
tower: [:]
]
}
def provider = new TowerFusionToken()
then: 'the endpoint has the expected value'
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
when: 'session config is null'
Global.session = Mock(Session) {
config >> null
}
then: 'the endpoint has the expected value'
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
when: 'session config is missing'
Global.session = Mock(Session) {
config >> [:]
}
then: 'the endpoint has the expected value'
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
when: 'session.config.tower.endpoint is not defined'
Global.session = Mock(Session) {
config >> [
tower: [:]
]
}
then: 'the endpoint has the expected value'
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
when: 'session.config.tower.endpoint is null'
Global.session = Mock(Session) {
config >> [
tower: [
endpoint: null
]
]
}
then: 'the endpoint has the expected value'
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
when: 'session.config.tower.endpoint is empty'
Global.session = Mock(Session) {
config >> [
tower: [
endpoint: ''
]
]
}
provider = new TowerFusionToken()
then: 'the endpoint has the expected value'
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
when: 'session.config.tower.endpoint is defined as "-"'
Global.session = Mock(Session) {
config >> [
tower: [
endpoint: '-'
]
]
}
provider = new TowerFusionToken()
then: 'the endpoint has the expected value'
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
cleanup:
SysEnv.pop()
}
def 'should return the access token from the config'() {
given: 'a session'
Global.session = Mock(Session) {
config >> [
tower: [
accessToken: 'abc123'
]
]
}
when: 'the provider is created'
def provider = new TowerFusionToken()
then: 'the access token has the expected value'
provider.accessToken == 'abc123'
}
def 'should return the access token from the environment'() {
setup:
Global.session = Mock(Session) {
config >> [:]
}
SysEnv.push(['TOWER_ACCESS_TOKEN': 'abc123'])
when: 'the provider is created'
def provider = new TowerFusionToken()
then: 'the access token has the expected value'
provider.accessToken == 'abc123'
cleanup:
SysEnv.pop()
}
def 'should prefer the access token from the config'() {
setup:
Global.session = Mock(Session) {
config >> [
tower: [
accessToken: 'abc123'
]
]
}
SysEnv.push(['TOWER_ACCESS_TOKEN': 'xyz789'])
when: 'the provider is created'
def provider = new TowerFusionToken()
then: 'the access token has the expected value'
provider.accessToken == 'abc123'
cleanup:
SysEnv.pop()
}
def 'should prefer the access token from the config despite being null'() {
setup:
Global.session = Mock(Session) {
config >> [
tower: [
accessToken: null
]
]
}
SysEnv.push(['TOWER_ACCESS_TOKEN': 'xyz789'])
when: 'the provider is created'
def provider = new TowerFusionToken()
then: 'the access token has the expected value'
def e = thrown(AbortRunException)
e.message.contains("Missing Seqera Platform access token")
cleanup:
SysEnv.pop()
}
def 'should prefer the access token from the environment if TOWER_WORKFLOW_ID is set'() {
setup:
Global.session = Mock(Session) {
config >> [
tower: [
accessToken: 'abc123'
]
]
}
SysEnv.push(['TOWER_ACCESS_TOKEN' : 'xyz789', 'TOWER_WORKFLOW_ID': '123'])
when: 'the provider is created'
def provider = new TowerFusionToken()
then: 'the access token has the expected value'
provider.accessToken == 'xyz789'
cleanup:
SysEnv.pop()
}
def 'should get a license token with config'() {
given:
def config = [
enabled : true,
endpoint : wireMockServer.baseUrl(),
accessToken: 'eyJ0aWQiOiAxMTkxN30uNWQ5MGFmYWU2YjhhNmFmY2FlNjVkMTQ4ZDFhM2ZlNzlmMmNjN2I4Mw==',
workspaceId: '67890'
]
def session = Mock(Session)
def meta = new WorkflowMetadata(
session: session,
projectName: 'the-project-name',
repository: 'git://repo.com/foo')
session.getConfig() >> [ tower: config ]
session.getUniqueId() >> UUID.randomUUID()
session.getWorkflowMetadata() >> meta
def PRODUCT = 'some-product'
def VERSION = 'some-version'
and:
Global.session = session
def provider = new TowerFusionToken()
and: 'a mock endpoint at flow create'
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/trace/create?workspaceId=${config.workspaceId}"))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withBody('{"message": "", "workflowId": "1234"}')
)
)
and:
def observer = new TowerFactory().create(session)[0]
observer.onFlowCreate(session)
and: 'a mock endpoint returning a valid token'
final now = Instant.now()
final expirationDate = toJson(now.plus(1, ChronoUnit.DAYS))
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/license/token/"))
.withHeader('Authorization', equalTo("Bearer ${config.accessToken}"))
.withRequestBody(matchingJsonPath('$.product', equalTo("some-product")))
.withRequestBody(matchingJsonPath('$.version', equalTo("some-version")))
.withRequestBody(matchingJsonPath('$.workspaceId', equalTo("67890")))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withHeader('Content-Type', 'application/json')
.withBody('{"signedToken":"xyz789", "expiresAt":' + expirationDate + '}')
)
)
when: 'a license token is requested'
final token = provider.getLicenseToken(PRODUCT, VERSION)
then: 'the token has the expected value'
token == 'xyz789'
and: 'the request is correct'
wireMockServer.verify(1, WireMock.postRequestedFor(WireMock.urlEqualTo("/license/token/"))
.withHeader('Authorization', WireMock.equalTo("Bearer ${config.accessToken}")))
}
def 'should get a license token with environment'() {
given:
def accessToken = 'eyJ0aWQiOiAxMTkxN30uNWQ5MGFmYWU2YjhhNmFmY2FlNjVkMTQ4ZDFhM2ZlNzlmMmNjN2I4Mw=='
def workspaceId = '67890'
SysEnv.push([
TOWER_WORKFLOW_ID: '12345',
TOWER_ACCESS_TOKEN: accessToken,
TOWER_WORKSPACE_ID: workspaceId,
TOWER_API_ENDPOINT: wireMockServer.baseUrl()
])
def session = Mock(Session)
def meta = new WorkflowMetadata(
session: session,
projectName: 'the-project-name',
repository: 'git://repo.com/foo')
session.getConfig() >> [:]
session.getUniqueId() >> UUID.randomUUID()
session.getWorkflowMetadata() >> meta
def PRODUCT = 'some-product'
def VERSION = 'some-version'
and:
Global.session = session
def provider = new TowerFusionToken()
and: 'a mock endpoint at flow create'
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/trace/create?workspaceId=${workspaceId}"))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withBody('{"message": "", "workflowId": "1234"}')
)
)
and:
def client = new TowerFactory().create(session)[0]
client.onFlowCreate(session)
and: 'a mock endpoint returning a valid token'
final now = Instant.now()
final expirationDate = toJson(now.plus(1, ChronoUnit.DAYS))
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/license/token/"))
.withHeader('Authorization', equalTo("Bearer $accessToken"))
.withRequestBody(matchingJsonPath('$.product', equalTo("some-product")))
.withRequestBody(matchingJsonPath('$.version', equalTo("some-version")))
.withRequestBody(matchingJsonPath('$.workspaceId', equalTo("${workspaceId}")))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withHeader('Content-Type', 'application/json')
.withBody('{"signedToken":"xyz789", "expiresAt":' + expirationDate + '}')
)
)
when: 'a license token is requested'
final token = provider.getLicenseToken(PRODUCT, VERSION)
then: 'the token has the expected value'
token == 'xyz789'
and: 'the request is correct'
wireMockServer.verify(1, WireMock.postRequestedFor(WireMock.urlEqualTo("/license/token/"))
.withHeader('Authorization', WireMock.equalTo("Bearer ${accessToken}")))
cleanup:
SysEnv.pop()
}
def 'should refresh the auth token on 401 and retry the request'() {
given:
def accessToken = 'eyJ0aWQiOiAxMTkxN30uNWQ5MGFmYWU2YjhhNmFmY2FlNjVkMTQ4ZDFhM2ZlNzlmMmNjN2I4Mw=='
def workspaceId = '67890'
SysEnv.push([
TOWER_WORKFLOW_ID: '12345',
TOWER_ACCESS_TOKEN: accessToken,
TOWER_REFRESH_TOKEN: 'xyz-refresh',
TOWER_WORKSPACE_ID: workspaceId,
TOWER_API_ENDPOINT: wireMockServer.baseUrl()
])
def session = Mock(Session)
def meta = new WorkflowMetadata(
session: session,
projectName: 'the-project-name',
repository: 'git://repo.com/foo')
session.getConfig() >> [:]
session.getUniqueId() >> UUID.randomUUID()
session.getWorkflowMetadata() >> meta
def PRODUCT = 'some-product'
def VERSION = 'some-version'
and:
Global.session = session
def provider = new TowerFusionToken()
and: 'a mock endpoint at flow create'
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/trace/create?workspaceId=${workspaceId}"))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withBody('{"message": "", "workflowId": "1234"}')
)
)
and:
def client = new TowerFactory().create(session)[0]
client.onFlowCreate(session)
and: 'prepare stubs'
final now = Instant.now()
final expirationDate = toJson(now.plus(1, ChronoUnit.DAYS))
// 1⃣ First attempt: /license/token/ fails with 401
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/license/token/"))
.withHeader('Authorization', equalTo("Bearer $accessToken"))
.inScenario("Refresh flow")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(WireMock.aResponse().withStatus(401))
.willSetStateTo("Token Refreshed")
)
// 2⃣ Refresh token call
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/oauth/access_token"))
.withHeader('Content-Type', equalTo('application/x-www-form-urlencoded'))
.withRequestBody(containing('grant_type=refresh_token'))
.withRequestBody(containing('refresh_token=xyz-refresh'))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withHeader('Set-Cookie', 'JWT=new-abc-token; Path=/; HttpOnly')
.withHeader('Set-Cookie', 'JWT_REFRESH_TOKEN=new-refresh-456; Path=/; HttpOnly')
.withBody('{"token_type":"Bearer"}')
)
)
// 3⃣ Retry: /license/token/ succeeds
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/license/token/"))
.withHeader('Authorization', equalTo('Bearer new-abc-token'))
.inScenario("Refresh flow")
.whenScenarioStateIs("Token Refreshed")
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withHeader('Content-Type', 'application/json')
.withBody('{"signedToken":"xyz789", "expiresAt":' + expirationDate + '}')
)
)
when:
final token = provider.getLicenseToken(PRODUCT, VERSION)
then:
token == 'xyz789'
and: 'verify that refresh endpoint was called'
wireMockServer.verify(1, WireMock.postRequestedFor(WireMock.urlEqualTo("/oauth/access_token")))
and: 'verify both requests to license endpoint'
wireMockServer.verify(2, WireMock.postRequestedFor(urlEqualTo("/license/token/")))
cleanup:
SysEnv.pop()
}
def 'should throw UnauthorizedException if getting a token fails with 401'() {
given: 'a TowerFusionEnv provider'
def config = [
enabled : true,
endpoint : wireMockServer.baseUrl(),
accessToken: 'eyJ0aWQiOiAxMTkxN30uNWQ5MGFmYWU2YjhhNmFmY2FlNjVkMTQ4ZDFhM2ZlNzlmMmNjN2I4Mw==',
workspaceId: '67890'
]
def session = Mock(Session)
def meta = new WorkflowMetadata(
session: session,
projectName: 'the-project-name',
repository: 'git://repo.com/foo')
session.getConfig() >> [ tower: config ]
session.getUniqueId() >> UUID.randomUUID()
session.getWorkflowMetadata() >> meta
and:
Global.session = session
def provider = new TowerFusionToken()
and: 'a mock endpoint at flow create'
wireMockServer.stubFor(
WireMock.post(urlEqualTo("/trace/create?workspaceId=${config.workspaceId}"))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withBody('{"message": "", "workflowId": "1234"}')
)
)
and:
def client = new TowerFactory().create(session)[0]
client.onFlowCreate(session)
and: 'a mock endpoint returning an error'
wireMockServer.stubFor(
WireMock.post(WireMock.urlEqualTo("/license/token/"))
.withHeader('Authorization', WireMock.equalTo("Bearer ${config.accessToken}"))
.willReturn(
WireMock.aResponse()
.withStatus(401)
.withHeader('Content-Type', 'application/json')
.withBody('{"error":"Unauthorized"}')
)
)
when: 'a license token is requested'
provider.getLicenseToken('some-product', 'some-version')
then: 'an exception is thrown'
thrown(UnauthorizedException)
}
def 'should deserialize response' () {
given:
def ts = Instant.ofEpochSecond(1738788914)
def json = '{"signedToken":"foo","expiresAt":"2025-02-05T20:55:14Z"}'
when:
def resp = TowerFusionToken.parseLicenseTokenResponse(json)
then:
resp.signedToken == 'foo'
resp.expiresAt == ts
}
}

View File

@@ -0,0 +1,273 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import java.time.Instant
import java.time.ZoneOffset
import groovy.json.JsonGenerator
import groovy.json.JsonSlurper
import nextflow.container.resolver.ContainerMeta
import nextflow.trace.ProgressRecord
import nextflow.trace.WorkflowStats
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class TowerJsonGeneratorTest extends Specification {
def 'should chomp too long values' () {
given:
def scheme = [foo: 5, 'bar.one': 5]
def gen = new TowerJsonGenerator(new JsonGenerator.Options(), scheme)
when:
def x = gen.toJson( [foo: "Hola", bar: 'mundo'] )
then:
x == '{"foo":"Hola","bar":"mundo"}'
when:
x = gen.toJson( [foo: "Hello world"] )
then:
x == '{"foo":"Hello"}'
when:
x = gen.toJson( [bar: [one: "Hello world", two: "Hola mundo"]] )
then:
x == '{"bar":{"one":"Hello","two":"Hola mundo"}}'
}
def 'should normalise gitmodules attribute' () {
given:
def scheme = ['workflow.manifest.gitmodules': 10]
def gen = new TowerJsonGenerator(new JsonGenerator.Options(), scheme)
when:
def json = gen.toJson( [workflow: [manifest: [gitmodules: ['a','b','c']]]] )
then:
json == '{"workflow":{"manifest":{"gitmodules":"a,b,c"}}}'
when:
json = gen.toJson( [workflow: [manifest: [gitmodules: 'abc']]] )
then:
json == '{"workflow":{"manifest":{"gitmodules":"abc"}}}'
when:
json = gen.toJson( [workflow: [manifest: [gitmodules: '123456789012345']]] )
then:
json == '{"workflow":{"manifest":{"gitmodules":"1234567890"}}}'
}
def 'should serialise progress records' () {
given:
def gen = new TowerJsonGenerator(new JsonGenerator.Options(), [:])
and:
def rec1 = new ProgressRecord(1, 'foo')
and:
def rec2 = new ProgressRecord(2, 'bar')
rec2.pending = 1
rec2.submitted = 2
rec2.running = 3
rec2.succeeded = 4
rec2.failed = 5
rec2.aborted = 6
rec2.stored = 7
rec2.ignored = 8
rec2.retries = 9
rec2.cached = 10
rec2.loadCpus = 11
rec2.loadMemory = 12
rec2.peakRunning = 13
rec2.peakCpus = 14
rec2.peakMemory = 15
rec2.terminated = true
when:
def json = gen.toJson([progress: [rec1, rec2]])
then:
def copy = (Map)new JsonSlurper().parseText(json)
copy.size() == 1
and:
def progress = (List<Map>)copy.progress
progress.size() == 2
and:
progress.get(0) == [
index:1,
name: 'foo',
workDir: null,
pending: 0,
submitted: 0,
running: 0,
succeeded: 0,
failed: 0,
aborted: 0,
stored: 0,
ignored: 0,
retries: 0,
cached: 0,
loadCpus: 0,
loadMemory: 0,
peakCpus: 0,
peakMemory: 0,
peakRunning: 0,
terminated: false]
and:
progress[1] == [
index:2,
name: 'bar',
workDir: null,
pending: 1,
submitted: 2,
running: 3,
succeeded: 4,
failed: 5,
aborted: 6,
stored: 7,
ignored: 8,
retries: 9,
cached: 10,
loadCpus: 11,
loadMemory: 12,
peakRunning: 13,
peakCpus: 14,
peakMemory: 15,
terminated: true]
}
def 'should serialise workflow stats' () {
given:
def gen = new TowerJsonGenerator(new JsonGenerator.Options(), [:])
and:
def rec1 = new ProgressRecord(1, 'foo')
def rec2 = new ProgressRecord(2, 'bar')
rec2.pending = 1
rec2.submitted = 2
rec2.running = 3
rec2.succeeded = 4
rec2.failed = 5
rec2.aborted = 6
rec2.stored = 7
rec2.ignored = 8
rec2.retries = 9
rec2.cached = 10
rec2.loadCpus = 11
rec2.loadMemory = 12
rec2.peakRunning = 13
rec2.peakCpus = 14
rec2.peakMemory = 15
rec2.terminated = true
and:
def stats = new WorkflowStats(
succeededCount: 1,
cachedCount: 2,
failedCount: 3,
ignoredCount: 4,
pendingCount: 5,
submittedCount: 6,
runningCount: 7,
retriesCount: 8,
abortedCount: 9,
records: [1:rec1, 2:rec2])
when:
def json = gen.toJson(new WorkflowProgress(stats))
then:
def copy = (Map)new JsonSlurper().parseText(json)
copy.succeeded == 1
copy.cached == 2
copy.failed == 3
copy.ignored == 4
copy.pending == 5
copy.submitted == 6
copy.running == 7
copy.retries == 8
copy.aborted == 9
and:
(copy.processes as List).size() == 2
and:
with(copy.processes[0] as Map) {
index == 1
name == 'foo'
pending == 0
submitted == 0
running == 0
succeeded == 0
failed == 0
aborted == 0
stored == 0
ignored == 0
retries == 0
cached == 0
loadCpus == 0
loadMemory == 0
peakCpus == 0
peakMemory == 0
peakRunning == 0
terminated == false
}
and:
with(copy.processes[1] as Map) {
index == 2
name == 'bar'
pending ==1
submitted == 2
running == 3
succeeded == 4
failed == 5
aborted == 6
stored == 7
ignored == 8
retries == 9
cached == 10
loadCpus == 11
loadMemory == 12
peakRunning == 13
peakCpus == 14
peakMemory == 15
terminated == true
}
}
def 'should serialise container meta' () {
given:
def gen = TowerJsonGenerator.create([:])
and:
def ts = Instant.ofEpochSecond(1742421070).atOffset(ZoneOffset.ofHours(2))
def c1 = new ContainerMeta(
requestId:'r-1',
requestTime:ts,
buildId: 'bd-2',
scanId: 'sc-3',
mirrorId: 'mr-4',
cached: false,
freeze: true,
sourceImage: 'debian:latest',
targetImage: 'wave/debian')
when:
def json = gen.toJson([containers: [c1]])
then:
json == '{"containers":[{"requestId":"r-1","sourceImage":"debian:latest","targetImage":"wave/debian","buildId":"bd-2","mirrorId":"mr-4","scanId":"sc-3","cached":false,"freeze":true,"requestTime":"2025-03-19T23:51:10+02:00"}]}'
}
}

View File

@@ -0,0 +1,591 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import java.nio.file.Files
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
import nextflow.Session
import nextflow.SysEnv
import nextflow.cloud.types.CloudMachineInfo
import nextflow.cloud.types.PriceModel
import nextflow.container.DockerConfig
import nextflow.container.resolver.ContainerMeta
import nextflow.exception.AbortRunException
import nextflow.script.PlatformMetadata
import nextflow.script.ScriptBinding
import nextflow.script.WorkflowMetadata
import nextflow.trace.TraceRecord
import nextflow.trace.WorkflowStats
import nextflow.trace.WorkflowStatsObserver
import nextflow.util.ProcessHelper
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class TowerObserverTest extends Specification {
protected boolean aroundNow(value) {
def now = Instant.now().toEpochMilli()
value > now-1_000 && value <= now
}
private TowerObserver newObserver(Session session, Map env = [:]) {
def client = Mock(TowerClient)
def observer = new TowerObserver(session, client, null, env)
observer.@reports = Mock(TowerReports)
return observer
}
def 'should create message map' () {
given:
def session = Mock(Session)
def params = new ScriptBinding.ParamsMap(x: "hello")
def meta = Mock(WorkflowMetadata)
def observer = Spy(newObserver(session))
observer.@workflowId = '12ef'
when:
def map = observer.makeCompleteReq(session)
then:
1 * session.getWorkflowMetadata() >> meta
1 * session.getParams() >> params
1 * meta.toMap() >> [foo:1, bar:2, container: [p1: 'c1', p2: 'c2']]
1 * observer.getMetricsList() >> [[process:'foo', cpu: [min: 1, max:5], time: [min: 6, max: 9]]]
1 * observer.getWorkflowProgress(false) >> new WorkflowProgress()
1 * observer.getOutFile() >> 'bar.out'
1 * observer.getLogFile() >> 'foo.out'
1 * observer.getOperationId() >> 'op-12345'
then:
map.workflow.foo == 1
map.workflow.bar == 2
map.workflow.id == '12ef'
map.workflow.params == [x: 'hello']
map.workflow.container == null
map.metrics == [[process:'foo', cpu: [min: 1, max:5], time: [min: 6, max: 9]]]
map.progress == new WorkflowProgress()
and:
aroundNow(map.instant)
and:
map.workflow.outFile == 'bar.out'
map.workflow.logFile == 'foo.out'
map.workflow.operationId == 'op-12345'
}
def 'should capitalise underscores' () {
given:
def tower = new TowerObserver(Mock(Session), Mock(TowerClient), "ws1234", [:] )
expect:
tower.underscoreToCamelCase(STR) == EXPECTED
where:
STR | EXPECTED
'abc' | 'abc'
'a_b_c' | 'aBC'
'foo__bar' | 'fooBar'
}
def 'should post task records' () {
given:
def session = Mock(Session)
def PROGRESS = Mock(WorkflowProgress) { getRunning()>>1; getSucceeded()>>2; getFailed()>>3 }
def observer = Spy(newObserver(session))
observer.@workflowId = 'xyz-123'
def nowTs = System.currentTimeMillis()
def submitTs = nowTs-2000
def startTs = nowTs-1000
def trace = new TraceRecord([
taskId: 10,
process: 'foo',
workdir: "/work/dir",
cpus: 1,
submit: submitTs,
start: startTs,
complete: nowTs ])
trace.executorName= 'batch'
trace.machineInfo = new CloudMachineInfo('m4.large', 'eu-west-1b', PriceModel.spot)
trace.containerMeta = new ContainerMeta(requestId: '12345', sourceImage: 'ubuntu:latest', targetImage: 'wave.io/12345/ubuntu:latest')
when:
def req = observer.makeTasksReq([trace])
then:
observer.getWorkflowProgress(true) >> PROGRESS
and:
req.tasks[0].taskId == 10
req.tasks[0].process == 'foo'
req.tasks[0].workdir == "/work/dir"
req.tasks[0].cpus == 1
req.tasks[0].submit == OffsetDateTime.ofInstant(Instant.ofEpochMilli(submitTs), ZoneId.systemDefault())
req.tasks[0].start == OffsetDateTime.ofInstant(Instant.ofEpochMilli(startTs), ZoneId.systemDefault())
req.tasks[0].executor == 'batch'
req.tasks[0].machineType == 'm4.large'
req.tasks[0].cloudZone == 'eu-west-1b'
req.tasks[0].priceModel == 'spot'
and:
req.progress.running == 1
req.progress.succeeded == 2
req.progress.failed == 3
and:
req.containers[0].requestId == '12345'
req.containers[0].sourceImage == 'ubuntu:latest'
req.containers[0].targetImage == 'wave.io/12345/ubuntu:latest'
and:
aroundNow(req.instant)
}
static now_millis = System.currentTimeMillis()
static now_instant = OffsetDateTime.ofInstant(Instant.ofEpochMilli(now_millis), ZoneId.systemDefault())
def 'should fix field types' () {
expect:
TowerObserver.fixTaskField(FIELD, VALUE) == EXPECTED
where:
FIELD | VALUE | EXPECTED
'foo' | 'hola' | 'hola'
'submit' | now_millis | now_instant
'start' | now_millis | now_instant
'complete' | now_millis | now_instant
'complete' | 0 | null
}
def 'should create workflow json' () {
given:
def sessionId = UUID.randomUUID()
def dir = Files.createTempDirectory('test')
def session = Mock(Session)
session.getUniqueId() >> sessionId
session.getRunName() >> 'foo'
session.config >> [:]
session.containerConfig >> new DockerConfig([:])
session.getParams() >> new ScriptBinding.ParamsMap([foo:'Hello', bar:'World'])
def meta = new WorkflowMetadata(
session: session,
projectName: 'the-project-name',
repository: 'git://repo.com/foo' )
session.getWorkflowMetadata() >> meta
session.getStatsObserver() >> Mock(WorkflowStatsObserver) { getStats() >> new WorkflowStats() }
def observer = Spy(newObserver(session, ENV))
observer.getOperationId() >> 'op-112233'
observer.getLogFile() >> 'log.file'
observer.getOutFile() >> 'out.file'
when:
def req1 = observer.makeCreateReq(session)
then:
req1.sessionId == sessionId.toString()
req1.runName == 'foo'
req1.projectName == 'the-project-name'
req1.repository == 'git://repo.com/foo'
req1.workflowId == WORKFLOW_ID
and:
aroundNow(req1.instant)
when:
def req = observer.makeBeginReq(session)
then:
observer.getWorkflowId() >> '12345'
and:
req.workflow.id == '12345'
req.workflow.params == [foo:'Hello', bar:'World']
req.workflow.outFile == 'out.file'
req.workflow.logFile == 'log.file'
req.workflow.operationId == 'op-112233'
and:
req.towerLaunch == TOWER_LAUNCH
and:
aroundNow(req.instant)
cleanup:
dir?.deleteDir()
where:
ENV | WORKFLOW_ID | TOWER_LAUNCH
[:] | null | false
[TOWER_WORKFLOW_ID: '1234'] | '1234' | true
}
def 'should convert map' () {
given:
def tower = new TowerObserver(Mock(Session), Mock(TowerClient), "ws1234", [:] )
expect:
tower.mapToString(null) == null
tower.mapToString('ciao') == 'ciao'
tower.mapToString([:]) == null
tower.mapToString([p:'foo', q:'bar']) == null
}
def 'should create init request' () {
given:
def uuid = UUID.randomUUID()
def meta = Mock(WorkflowMetadata) {
getProjectName() >> 'the-project-name'
getRepository() >> 'git://repo.com/foo'
}
def session = Mock(Session) {
getUniqueId() >> uuid
getRunName() >> 'foo_bar'
getWorkflowMetadata() >> meta
}
def observer = newObserver(session, [TOWER_WORKFLOW_ID: 'x123'])
when:
def req = observer.makeCreateReq(session)
then:
req.sessionId == uuid.toString()
req.runName == 'foo_bar'
req.projectName == 'the-project-name'
req.repository == 'git://repo.com/foo'
req.workflowId == 'x123'
and:
aroundNow(req.instant)
and:
observer.towerLaunch
}
def 'should post create request' () {
given:
def uuid = UUID.randomUUID()
def platform = new PlatformMetadata()
def meta = Mock(WorkflowMetadata) {
getProjectName() >> 'the-project-name'
getRepository() >> 'git://repo.com/foo'
getPlatform() >> platform
}
def session = Mock(Session) {
getUniqueId() >> uuid
getRunName() >> 'foo_bar'
getWorkflowMetadata() >> meta
}
def towerClient = Mock(TowerClient)
def observer = Spy(new TowerObserver(session, towerClient, null, [:]))
observer.@reports = Mock(TowerReports)
when:
observer.onFlowCreate(session)
then:
1 * observer.makeCreateReq(session) >> [runName: 'foo']
1 * towerClient.traceCreate([runName: 'foo'], null) >> [workflowId: 'xyz123', watchUrl: 'https://cloud.seqera.io/watch/xyz123']
and:
observer.runName == 'foo_bar'
observer.runId == uuid.toString()
and:
observer.workflowId == 'xyz123'
observer.@watchUrl == 'https://cloud.seqera.io/watch/xyz123'
!observer.towerLaunch
and:
platform.workflowId == 'xyz123'
platform.workflowUrl == 'https://cloud.seqera.io/watch/xyz123'
}
def 'should set workflowUrl on platform metadata during onFlowBegin' () {
given:
def platform = new PlatformMetadata()
def meta = Mock(WorkflowMetadata) {
getPlatform() >> platform
}
def session = Mock(Session) {
getWorkflowMetadata() >> meta
}
def towerClient = Mock(TowerClient)
def observer = Spy(new TowerObserver(session, towerClient, null, [:]))
observer.@reports = Mock(TowerReports)
observer.@workflowId = 'abc123'
when:
observer.onFlowBegin()
then:
1 * observer.makeBeginReq(session) >> [foo: 'bar']
1 * towerClient.traceBegin([foo: 'bar'], null, 'abc123') >> [watchUrl: 'https://cloud.seqera.io/watch/abc123']
and:
observer.@watchUrl == 'https://cloud.seqera.io/watch/abc123'
platform.workflowUrl == 'https://cloud.seqera.io/watch/abc123'
cleanup:
observer.@sender?.interrupt()
}
def 'should fetch workflow meta' () {
given:
def session = Mock(Session)
def observer = newObserver(session, ENV)
expect:
observer.getOperationId() == OP_ID
observer.getLogFile() == LOG_FILE
observer.getOutFile() == OUT_FILE
where:
OP_ID | OUT_FILE | LOG_FILE | ENV
null | null | null | [:]
"local-platform::${ProcessHelper.selfPid()}" | null | null | [TOWER_ALLOW_NEXTFLOW_LOGS:'true']
'aws-batch::1234z' | 'xyz.out' | 'hola.log' | [TOWER_ALLOW_NEXTFLOW_LOGS:'true', AWS_BATCH_JOB_ID: '1234z', NXF_OUT_FILE: 'xyz.out', NXF_LOG_FILE: 'hola.log']
}
def 'should deduplicate containers' () {
given:
def session = Mock(Session)
def observer = newObserver(session)
and:
def c1 = new ContainerMeta(requestId: '12345', sourceImage: 'ubuntu:latest', targetImage: 'wave.io/12345/ubuntu:latest')
def c2 = new ContainerMeta(requestId: '54321', sourceImage: 'ubuntu:latest', targetImage: 'wave.io/54321/ubuntu:latest')
and:
def trace1 = new TraceRecord(
taskId: 1,
process: 'foo',
workdir: "/work/dir",
cpus: 1,
submit: System.currentTimeMillis(),
start: System.currentTimeMillis(),
complete: System.currentTimeMillis())
trace1.containerMeta = c1
and:
def trace2 = new TraceRecord(
taskId: 2,
process: 'foo',
workdir: "/work/dir",
cpus: 1,
submit: System.currentTimeMillis(),
start: System.currentTimeMillis(),
complete: System.currentTimeMillis())
trace2.containerMeta = c2
and:
def trace3 = new TraceRecord(
taskId: 3,
process: 'foo',
workdir: "/work/dir",
cpus: 1,
submit: System.currentTimeMillis(),
start: System.currentTimeMillis(),
complete: System.currentTimeMillis())
trace3.containerMeta = c2
expect:
observer.getNewContainers([trace1]) == [c1]
and:
observer.getNewContainers([trace1]) == []
and:
observer.getNewContainers([trace1, trace2, trace3]) == [c2]
}
def 'should not send complete request when onFlowBegin was not invoked' () {
given:
def session = Mock(Session)
def towerClient = Mock(TowerClient)
def observer = Spy(new TowerObserver(session, towerClient, null, [:]))
def reports = Mock(TowerReports)
observer.@reports = reports
observer.@workflowId = 'xyz-123'
observer.@sender = null
when:
observer.onFlowComplete()
then:
1 * reports.publishRuntimeReports()
1 * reports.flowComplete()
0 * towerClient.traceComplete(_, _, _)
}
def 'should apply platform metadata from trace create response'() {
given:
def metadata = new WorkflowMetadata()
def session = Mock(Session) {
getWorkflowMetadata() >> metadata
}
def observer = new TowerObserver(session, Mock(TowerClient), '1234', SysEnv.get())
def responseMetadata = [
userId: 39,
userName: 'user',
userOrganization: 'ACME Inc.',
workspaceId: 1234,
workspaceName: 'Workspace-Name',
workspaceFullName: 'Full Workspace Name',
orgName: 'ACME Inc.',
computeEnvId: 'ce1234',
computeEnvName: 'ce-test',
computeEnvPlatform: 'aws-batch',
pipelineName: 'test-pipeline',
pipelineId: 'pipe1234',
revision: 'v1.1',
commitId: 'abcd12345'
]
when:
observer.applyPlatformMetadata(responseMetadata)
then:
metadata.platform.user.id == '39'
metadata.platform.user.userName == 'user'
metadata.platform.user.organization == 'ACME Inc.'
metadata.platform.workspace.id == '1234'
metadata.platform.workspace.name == 'Workspace-Name'
metadata.platform.workspace.fullName == 'Full Workspace Name'
metadata.platform.workspace.organization == 'ACME Inc.'
metadata.platform.computeEnv.id == 'ce1234'
metadata.platform.computeEnv.name == 'ce-test'
metadata.platform.computeEnv.platform == 'aws-batch'
metadata.platform.pipeline.id == 'pipe1234'
metadata.platform.pipeline.name == 'test-pipeline'
metadata.platform.pipeline.revision == 'v1.1'
metadata.platform.pipeline.commitId == 'abcd12345'
}
def 'should include numSpotInterruptions in task map'() {
given:
def session = Mock(Session)
def observer = Spy(newObserver(session))
observer.getWorkflowProgress(true) >> new WorkflowProgress()
def now = System.currentTimeMillis()
def trace = new TraceRecord([
taskId: 42,
process: 'foo',
workdir: "/work/dir",
cpus: 1,
submit: now-2000,
start: now-1000,
complete: now
])
trace.setNumSpotInterruptions(3)
when:
def req = observer.makeTasksReq([trace])
then:
req.tasks.size() == 1
req.tasks[0].numSpotInterruptions == 3
}
def 'should include logStreamId in task map'() {
given:
def session = Mock(Session)
def observer = Spy(newObserver(session))
observer.getWorkflowProgress(true) >> new WorkflowProgress()
def now = System.currentTimeMillis()
def trace = new TraceRecord([
taskId: 42,
process: 'foo',
workdir: "/work/dir",
cpus: 1,
submit: now-2000,
start: now-1000,
complete: now
])
trace.setLogStreamId('arn:aws:logs:us-east-1:123456789:log-group:/ecs/task:log-stream:abc123')
when:
def req = observer.makeTasksReq([trace])
then:
req.tasks.size() == 1
req.tasks[0].logStreamId == 'arn:aws:logs:us-east-1:123456789:log-group:/ecs/task:log-stream:abc123'
}
def 'should include resourceAllocation in task map'() {
given:
def session = Mock(Session)
def observer = Spy(newObserver(session))
observer.getWorkflowProgress(true) >> new WorkflowProgress()
def now = System.currentTimeMillis()
def trace = new TraceRecord([
taskId: 42,
process: 'foo',
workdir: "/work/dir",
cpus: 1,
submit: now-2000,
start: now-1000,
complete: now,
accelerator: 2,
acceleratorType: 'v100'
])
trace.setResourceAllocation([cpuShares: 2048, memoryMiB: 4096, time: '1h'])
when:
def req = observer.makeTasksReq([trace])
then:
req.tasks.size() == 1
req.tasks[0].accelerator == 2
req.tasks[0].acceleratorType == 'v100'
req.tasks[0].resourceAllocation == [cpuShares: 2048, memoryMiB: 4096, time: '1h']
}
def 'should include gpuMetrics in task map'() {
given:
def session = Mock(Session)
def observer = Spy(newObserver(session))
observer.getWorkflowProgress(true) >> new WorkflowProgress()
def now = System.currentTimeMillis()
def trace = new TraceRecord([
taskId: 42,
process: 'foo',
workdir: "/work/dir",
cpus: 1,
submit: now-2000,
start: now-1000,
complete: now
])
trace.setGpuMetrics([name: 'Tesla T4', mem: 15360, driver: '580.126.09', active_time: 651030, pct: 75, peak: 100])
when:
def req = observer.makeTasksReq([trace])
then:
req.tasks.size() == 1
req.tasks[0].gpuMetrics.name == 'Tesla T4'
req.tasks[0].gpuMetrics.mem == 15360
req.tasks[0].gpuMetrics.pct == 75
req.tasks[0].gpuMetrics.peak == 100
}
def 'should throw AbortRunException if workflow id is not found'() {
given:
def session = Mock(Session){
getUniqueId() >> UUID.randomUUID()
getWorkflowMetadata() >> Mock(WorkflowMetadata)
}
def client = Mock(TowerClient){
traceCreate(_,_) >> [:]
}
def observer = new TowerObserver(session, client, null, [:])
when:
observer.onFlowCreate(session)
then:
thrown(AbortRunException)
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import nextflow.Session
import spock.lang.Specification
/**
*
* @author Jordi Deu-Pons <jordi@seqera.io>
*/
class TowerReportsTest extends Specification {
def 'should convert to glob pattern'() {
expect:
TowerReports.convertToGlobPattern(KEY) == EXPECTED
where:
KEY | EXPECTED
"multiqc.html" | "glob:**/multiqc.html"
"**/multiqc.html" | "glob:**/multiqc.html"
"*/multiqc.html" | "glob:**/*/multiqc.html"
"reports/*.html" | "glob:**/reports/*.html"
}
def 'should load reports from tower yml file'() {
given:
def launchDir = File.createTempDir()
def workflowId = "1khBUM1SUioskd"
def config = new File(launchDir, "nf-${workflowId}-tower.yml")
config.text = """
reports:
multiqc_report.html:
display: "MultiQC HTML report"
deseq2.plots.pdf:
display: "All samples STAR Salmon DESeq2 QC PDF plots"
salmon.merged.gene_counts.tsv:
display: "All samples STAR Salmon merged gene raw counts"
"*.merged.gene_counts.tsv":
display: "All samples STAR Salmon merged gene raw counts"
""".stripIndent()
when:
def reports = new TowerReports(Mock(Session))
def entries = reports.parseReportEntries(launchDir.toPath(), workflowId)
then:
entries == [
"multiqc_report.html" : ["display": "MultiQC HTML report"],
"deseq2.plots.pdf" : ["display": "All samples STAR Salmon DESeq2 QC PDF plots"],
"salmon.merged.gene_counts.tsv": ["display": "All samples STAR Salmon merged gene raw counts"],
"*.merged.gene_counts.tsv" : ["display": "All samples STAR Salmon merged gene raw counts"]
].collect()
}
def 'should generate reports file'() {
given: 'a launch directory with a tower yaml file'
def launchDir = File.createTempDir()
def workflowId = "1khBUM1SUioskd"
def config = new File(launchDir, "nf-${workflowId}-tower.yml")
config.text = """
reports:
multiqc_report.html:
display: "MultiQC HTML report"
deseq2.plots.pdf:
display: "All samples STAR Salmon DESeq2 QC PDF plots"
mimeType: "application/pdf"
"*.merged.gene_counts.tsv":
display: "All samples STAR Salmon merged gene raw counts"
""".stripIndent()
and: 'a tower reports instance'
def session = new Session()
TowerReports reports = Spy(TowerReports, constructorArgs: [session])
reports.launchDir >> launchDir.toPath()
and: 'some reports'
def repo1 = new File(launchDir, "multiqc_report.html")
repo1.text = "html"
def repo2 = new File(launchDir, "deseq2.plots.pdf")
repo2.text = "pdf"
def repo3 = new File(launchDir, "salmon.merged.gene_counts.tsv")
repo3.text = "tsv"
when: 'a workflow runs'
reports.flowCreate(workflowId)
reports.filePublish(repo1.toPath())
reports.filePublish(repo2.toPath())
reports.filePublish(repo3.toPath())
reports.flowComplete()
then:
def result = new File(launchDir, "nf-${workflowId}-reports.tsv")
result.text == "key\tpath\tsize\tdisplay\tmime_type\n" +
"multiqc_report.html\t${repo1.toPath().toUriString()}\t4\tMultiQC HTML report\t\n" +
"deseq2.plots.pdf\t${repo2.toPath().toUriString()}\t3\tAll samples STAR Salmon DESeq2 QC PDF plots\tapplication/pdf\n" +
"*.merged.gene_counts.tsv\t${repo3.toPath().toUriString()}\t3\tAll samples STAR Salmon merged gene raw counts\t\n"
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin
import nextflow.util.Duration
import nextflow.util.RetryConfig
import spock.lang.Specification
/**
* Unit tests for TowerRetryPolicy
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class TowerRetryPolicyTest extends Specification {
def 'should validate default values of tower retry policy'() {
when:
def policy = new TowerRetryPolicy([:])
then:
policy.delay == RetryConfig.DEFAULT_DELAY
policy.maxDelay == RetryConfig.DEFAULT_MAX_DELAY
policy.maxAttempts == RetryConfig.DEFAULT_MAX_ATTEMPTS
policy.jitter == RetryConfig.DEFAULT_JITTER
policy.multiplier == RetryConfig.DEFAULT_MULTIPLIER
}
def 'should use provided values when specified'() {
when:
def customOptions = [
delay: '1s' as nextflow.util.Duration,
maxDelay: '60s' as nextflow.util.Duration,
maxAttempts: 3,
jitter: 0.5,
multiplier: 1.5
]
def policy = new TowerRetryPolicy(customOptions)
then:
policy.delay == customOptions.delay
policy.maxDelay == customOptions.maxDelay
policy.maxAttempts == 3
policy.jitter == 0.5d
policy.multiplier == 1.5d
}
def 'should use provided values when specified'() {
when:
def policy = new TowerRetryPolicy([:], [backOffDelay: 500, maxRetries: 100, backOffBase: 5])
then:
policy.delay == Duration.of('500ms')
policy.maxAttempts == 100
policy.multiplier == 5
and:
policy.maxDelay == RetryConfig.DEFAULT_MAX_DELAY
policy.jitter == RetryConfig.DEFAULT_JITTER
}
}

View File

@@ -0,0 +1,217 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin.dataset
import java.nio.file.AccessDeniedException
import java.nio.file.NoSuchFileException
import groovy.json.JsonOutput
import io.seqera.tower.plugin.TowerClient
import io.seqera.tower.plugin.exception.ForbiddenException
import io.seqera.tower.plugin.exception.NotFoundException
import io.seqera.tower.plugin.exception.UnauthorizedException
import nextflow.exception.AbortOperationException
import spock.lang.Specification
/**
* Tests for {@link SeqeraDatasetClient} using a mock {@link TowerClient}.
*/
class SeqeraDatasetClientTest extends Specification {
private TowerClient mockTower(String endpoint = 'https://api.example.com') {
def tc = Mock(TowerClient)
tc.endpoint >> endpoint
return tc
}
private TowerClient spyTower(String endpoint = 'https://api.example.com') {
def tc = Spy(TowerClient)
tc.@endpoint = endpoint
return tc
}
private static TowerClient.Response ok(String body) {
new TowerClient.Response(200, body)
}
private static TowerClient.Response error(int code) {
new TowerClient.Response(code, "error $code")
}
// ---- listUserWorkspacesAndOrgs ----
def "listUserWorkspacesAndOrgs returns parsed DTOs"() {
given:
def body = JsonOutput.toJson([orgsAndWorkspaces: [
[orgId: 1, orgName: 'acme', workspaceId: 10, workspaceName: 'research', workspaceFullName: 'acme/research']
]])
def tc = spyTower()
tc.sendApiRequest('https://api.example.com/user/42/workspaces') >> ok(body)
def client = new SeqeraDatasetClient(tc)
when:
def list = client.listUserWorkspacesAndOrgs(42L)
then:
list.size() == 1
list[0].orgName == 'acme'
list[0].workspaceId == 10L
list[0].workspaceName == 'research'
}
// ---- listDatasets ----
def "listDatasets returns parsed DatasetDto list"() {
given:
def body = JsonOutput.toJson([datasets: [
[id: 'ds-1', name: 'samples', version: 2, mediaType: 'text/csv',
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
], totalSize: 1])
def tc = mockTower()
tc.sendApiRequest('https://api.example.com/datasets?workspaceId=99') >> ok(body)
def client = new SeqeraDatasetClient(tc)
when:
def list = client.listDatasets(99L)
then:
list.size() == 1
list[0].id == 'ds-1'
list[0].name == 'samples'
list[0].version == 2L
}
def "listDatasets returns empty list when no datasets"() {
given:
def tc = mockTower()
tc.sendApiRequest('https://api.example.com/datasets?workspaceId=99') >>
ok(JsonOutput.toJson([datasets: [], totalSize: 0]))
def client = new SeqeraDatasetClient(tc)
when:
def list = client.listDatasets(99L)
then:
list.isEmpty()
}
// ---- listVersions ----
def "listVersions returns parsed DatasetVersionDto list"() {
given:
def body = JsonOutput.toJson([versions: [
[datasetId: 'ds-1', version: 1, fileName: 'samples.csv',
mediaType: 'text/csv', hasHeader: true, dateCreated: '2024-01-01T00:00:00Z', disabled: false]
]])
def tc = mockTower()
tc.sendApiRequest('https://api.example.com/datasets/ds-1/versions?workspaceId=1234') >> ok(body)
def client = new SeqeraDatasetClient(tc)
when:
def list = client.listVersions('ds-1', 1234)
then:
list.size() == 1
list[0].version == 1L
list[0].fileName == 'samples.csv'
list[0].hasHeader
!list[0].disabled
}
// ---- downloadDataset ----
def "downloadDataset returns InputStream with correct content"() {
given:
def content = 'col1,col2\n1,2\n'
def tc = mockTower()
tc.sendStreamingRequest('https://api.example.com/datasets/ds-1/v/1/n/samples.csv?workspaceId=1234') >> new ByteArrayInputStream(content.getBytes('UTF-8'))
def client = new SeqeraDatasetClient(tc)
when:
def stream = client.downloadDataset('ds-1', '1', 'samples.csv', 1234)
then:
stream.text == content
}
def "downloadDataset URL-encodes the filename"() {
given:
def tc = mockTower()
def client = new SeqeraDatasetClient(tc)
when:
client.downloadDataset('ds-1', '1', 'my file.csv',1234)
then:
1 * tc.sendStreamingRequest('https://api.example.com/datasets/ds-1/v/1/n/my%20file.csv?workspaceId=1234') >> new ByteArrayInputStream('data'.getBytes('UTF-8'))
}
def "downloadDataset throws NoSuchFileException on 404"() {
given:
def tc = mockTower()
tc.sendStreamingRequest(_) >> { throw new NotFoundException("not found") }
def client = new SeqeraDatasetClient(tc)
when:
client.downloadDataset('ds-missing', '1', 'file.csv', 1234)
then:
thrown(NoSuchFileException)
}
def "downloadDataset throws AccessDeniedException on 403"() {
given:
def tc = mockTower()
tc.sendStreamingRequest(_) >> { throw new ForbiddenException("forbidden") }
def client = new SeqeraDatasetClient(tc)
when:
client.downloadDataset('ds-1', '1', 'file.csv', 1234)
then:
thrown(AccessDeniedException)
}
def "downloadDataset throws AbortOperationException on 401"() {
given:
def tc = mockTower()
tc.sendStreamingRequest(_) >> { throw new UnauthorizedException("unauthorized") }
def client = new SeqeraDatasetClient(tc)
when:
client.downloadDataset('ds-1', '1', 'file.csv', 1234)
then:
thrown(AbortOperationException)
}
// ---- createDataset ----
def "createDataset posts and returns created dataset"() {
given:
def responseBody = JsonOutput.toJson([dataset: [id: 'ds-new', name: 'results']])
def tc = mockTower()
tc.sendApiRequest('https://api.example.com/datasets?workspaceId=10', [name: 'results'], 'POST') >> ok(responseBody)
def client = new SeqeraDatasetClient(tc)
when:
def dto = client.createDataset(10L, 'results')
then:
dto.id == 'ds-new'
dto.name == 'results'
}
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin.fs
import java.nio.ByteBuffer
import spock.lang.Specification
class DatasetInputStreamTest extends Specification {
def 'should read bytes into buffer'() {
given:
def data = 'hello world'.bytes
def channel = new DatasetInputStream(new ByteArrayInputStream(data))
def buf = ByteBuffer.allocate(data.length)
when:
def n = channel.read(buf)
then:
n == data.length
buf.array() == data
}
def 'should advance position after read'() {
given:
def data = 'abcdef'.bytes
def channel = new DatasetInputStream(new ByteArrayInputStream(data))
when:
channel.read(ByteBuffer.allocate(3))
then:
channel.position() == 3
when:
channel.read(ByteBuffer.allocate(3))
then:
channel.position() == 6
}
def 'should return -1 at end of stream'() {
given:
def channel = new DatasetInputStream(new ByteArrayInputStream(new byte[0]))
when:
def n = channel.read(ByteBuffer.allocate(8))
then:
n == -1
channel.position() == 0
}
def 'should read partial buffer when stream has fewer bytes'() {
given:
def data = 'hi'.bytes
def channel = new DatasetInputStream(new ByteArrayInputStream(data))
def buf = ByteBuffer.allocate(100)
when:
def n = channel.read(buf)
then:
n == 2
channel.position() == 2
}
def 'should be open initially and closed after close()'() {
given:
def channel = new DatasetInputStream(new ByteArrayInputStream(new byte[0]))
expect:
channel.isOpen()
when:
channel.close()
then:
!channel.isOpen()
}
def 'should close underlying stream on close()'() {
given:
def stream = Mock(InputStream)
def channel = new DatasetInputStream(stream)
when:
channel.close()
then:
1 * stream.close()
!channel.isOpen()
}
def 'should throw on size'() {
when:
new DatasetInputStream(new ByteArrayInputStream(new byte[0])).size()
then:
thrown(UnsupportedOperationException)
}
def 'should throw on write'() {
when:
new DatasetInputStream(new ByteArrayInputStream(new byte[0])).write(ByteBuffer.allocate(1))
then:
thrown(UnsupportedOperationException)
}
def 'should throw on seek'() {
when:
new DatasetInputStream(new ByteArrayInputStream(new byte[0])).position(0L)
then:
thrown(UnsupportedOperationException)
}
def 'should throw on truncate'() {
when:
new DatasetInputStream(new ByteArrayInputStream(new byte[0])).truncate(0L)
then:
thrown(UnsupportedOperationException)
}
}

View File

@@ -0,0 +1,411 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin.fs
import java.nio.file.AccessDeniedException
import java.nio.file.DirectoryStream
import java.nio.file.FileSystemAlreadyExistsException
import java.nio.file.InvalidPathException
import java.nio.file.NoSuchFileException
import java.nio.file.attribute.BasicFileAttributes
import groovy.json.JsonOutput
import io.seqera.tower.plugin.TowerClient
import io.seqera.tower.plugin.dataset.SeqeraDatasetClient
import nextflow.exception.AbortOperationException
import spock.lang.Specification
/**
* Tests for {@link SeqeraFileSystemProvider} using a mock {@link TowerClient}.
*/
class SeqeraFileSystemProviderTest extends Specification {
private static final String ENDPOINT = 'https://api.example.com'
private TowerClient spyTower() {
def tc = Spy(TowerClient)
tc.@endpoint = ENDPOINT
return tc
}
private static TowerClient.Response ok(String body) {
new TowerClient.Response(200, body)
}
private static TowerClient.Response error(int code) {
new TowerClient.Response(code, "error $code")
}
private SeqeraFileSystem buildFs(TowerClient tc) {
final client = new SeqeraDatasetClient(tc)
final provider = new SeqeraFileSystemProvider()
return new SeqeraFileSystem(provider, client)
}
private static String userInfoJson() {
JsonOutput.toJson([user: [id: 42L, userName: 'testuser']])
}
private static String workspacesJson() {
JsonOutput.toJson([orgsAndWorkspaces: [
[orgId: 1L, orgName: 'acme', workspaceId: 10L, workspaceName: 'research', workspaceFullName: 'acme/research']
]])
}
private static String datasetsJson() {
JsonOutput.toJson([datasets: [
[id: 'ds-1', name: 'samples', version: 2L, mediaType: 'text/csv',
workspaceId: 10L,
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
], totalSize: 1])
}
private static String versionsJson() {
JsonOutput.toJson([versions: [
[datasetId: 'ds-1', version: 1L, fileName: 'samples.csv',
mediaType: 'text/csv', hasHeader: true, dateCreated: '2024-01-01T00:00:00Z', disabled: false],
[datasetId: 'ds-1', version: 2L, fileName: 'samples_v2.csv',
mediaType: 'text/csv', hasHeader: true, dateCreated: '2024-01-02T00:00:00Z', disabled: false]
]])
}
// ---- newInputStream - latest version ----
def "newInputStream resolves latest version and downloads correct content"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
tc.sendApiRequest("${ENDPOINT}/datasets/ds-1/versions?workspaceId=10") >> ok(versionsJson())
final csvContent = 'col1,col2\n1,2\n3,4\n'
tc.sendStreamingRequest("${ENDPOINT}/datasets/ds-1/v/2/n/samples_v2.csv?workspaceId=10") >> new ByteArrayInputStream(csvContent.getBytes('UTF-8'))
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
when:
final text = fs.provider().newInputStream(path).text
then:
text == csvContent
}
// ---- newInputStream - pinned version ----
def "newInputStream uses pinned version when @ver suffix given"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
tc.sendApiRequest("${ENDPOINT}/datasets/ds-1/versions?workspaceId=10") >> ok(versionsJson())
final csvContent = 'col1,col2\n1,2\n'
tc.sendStreamingRequest("${ENDPOINT}/datasets/ds-1/v/1/n/samples.csv?workspaceId=10") >> new ByteArrayInputStream(csvContent.getBytes('UTF-8'))
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples@1')
when:
final text = fs.provider().newInputStream(path).text
then:
text == csvContent
}
// ---- newInputStream - missing dataset ----
def "newInputStream throws NoSuchFileException for unknown dataset"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >>
ok(JsonOutput.toJson([datasets: [], totalSize: 0]))
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/missing-dataset')
when:
fs.provider().newInputStream(path)
then:
thrown(NoSuchFileException)
}
// ---- newInputStream - pinned version not found ----
def "newInputStream throws NoSuchFileException for unknown pinned version"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
tc.sendApiRequest("${ENDPOINT}/datasets/ds-1/versions?workspaceId=10") >> ok(versionsJson())
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples@99')
when:
fs.provider().newInputStream(path)
then:
thrown(NoSuchFileException)
}
// ---- readAttributes ----
def "readAttributes returns directory attributes for depth < 4"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research')
when:
final attrs = fs.provider().readAttributes(path, java.nio.file.attribute.BasicFileAttributes)
then:
attrs.isDirectory()
!attrs.isRegularFile()
}
def "readAttributes returns file attributes for dataset path"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
when:
final attrs = fs.provider().readAttributes(path, BasicFileAttributes)
then:
!attrs.isDirectory()
attrs.isRegularFile()
}
// ---- newDirectoryStream (T023) ----
def "newDirectoryStream on root returns org names"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
final root = new SeqeraPath(fs, 'seqera://')
when:
def entries = fs.provider().newDirectoryStream(root, null).toList()
then:
entries.size() == 1
entries[0].toString() == 'seqera://acme'
}
def "newDirectoryStream on org returns workspace names"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
final orgPath = new SeqeraPath(fs, 'seqera://acme')
when:
def entries = fs.provider().newDirectoryStream(orgPath, null).toList()
then:
entries.size() == 1
entries[0].toString() == 'seqera://acme/research'
}
def "newDirectoryStream on workspace returns datasets resource type"() {
given:
def tc = spyTower()
final fs = buildFs(tc)
final wsPath = new SeqeraPath(fs, 'seqera://acme/research')
when:
def entries = fs.provider().newDirectoryStream(wsPath, null).toList()
then:
entries.size() == 1
entries[0].toString() == 'seqera://acme/research/datasets'
}
def "newDirectoryStream on datasets dir returns dataset names"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
final fs = buildFs(tc)
final dsDir = new SeqeraPath(fs, 'seqera://acme/research/datasets')
when:
def entries = fs.provider().newDirectoryStream(dsDir, null).toList()
then:
entries.size() == 1
entries[0].toString() == 'seqera://acme/research/datasets/samples'
}
def "newDirectoryStream on datasets dir with empty workspace returns empty stream"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >>
ok(JsonOutput.toJson([datasets: [], totalSize: 0]))
final fs = buildFs(tc)
final dsDir = new SeqeraPath(fs, 'seqera://acme/research/datasets')
when:
def entries = fs.provider().newDirectoryStream(dsDir, null).toList()
then:
entries.isEmpty()
}
def "newDirectoryStream filter is applied to entries"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(JsonOutput.toJson([datasets: [
[id: 'ds-1', name: 'samples', version: 1L, mediaType: 'text/csv', workspaceId: 10L,
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z'],
[id: 'ds-2', name: 'results', version: 1L, mediaType: 'text/csv', workspaceId: 10L,
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
], totalSize: 2]))
final fs = buildFs(tc)
final dsDir = new SeqeraPath(fs, 'seqera://acme/research/datasets')
final filter = { java.nio.file.Path p -> p.toString().contains('results') } as DirectoryStream.Filter
when:
def entries = fs.provider().newDirectoryStream(dsDir, filter).toList()
then:
entries.size() == 1
entries[0].toString() == 'seqera://acme/research/datasets/results'
}
// ---- error scenarios (T028) ----
def "readAttributes throws NoSuchFileException for unknown org"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://unknown-org/research')
when:
fs.provider().readAttributes(path, BasicFileAttributes)
then:
thrown(NoSuchFileException)
}
def "newInputStream throws NoSuchFileException containing dataset name for missing dataset"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >>
ok(JsonOutput.toJson([datasets: [], totalSize: 0]))
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/missing-dataset')
when:
fs.provider().newInputStream(path)
then:
def e = thrown(NoSuchFileException)
e.file?.contains('missing-dataset')
}
def "getUserInfo 401 propagates as AbortOperationException"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> new TowerClient.Response(401, 'Unauthorized')
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
when:
fs.provider().newInputStream(path)
then:
thrown(AbortOperationException)
}
def "getUserInfo 403 propagates as AccessDeniedException"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> new TowerClient.Response(403, 'Forbidden')
final fs = buildFs(tc)
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
when:
fs.provider().newInputStream(path)
then:
thrown(AccessDeniedException)
}
def "SeqeraPath constructor throws InvalidPathException for path with empty workspace segment"() {
given:
def tc = spyTower()
final fs = buildFs(tc)
when:
new SeqeraPath(fs, 'seqera://acme//datasets/samples')
then:
thrown(InvalidPathException)
}
// ---- newFileSystem contract ----
def "newFileSystem throws FileSystemAlreadyExistsException when filesystem exists"() {
given: 'a provider with an existing filesystem'
def tc = spyTower()
def provider = new SeqeraFileSystemProvider()
def fs = new SeqeraFileSystem(provider, new SeqeraDatasetClient(tc))
provider.@fileSystem = fs
when:
provider.newFileSystem(new URI('seqera://test'), [:])
then:
thrown(FileSystemAlreadyExistsException)
}
}

View File

@@ -0,0 +1,204 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin.fs
import java.nio.file.NoSuchFileException
import groovy.json.JsonOutput
import io.seqera.tower.plugin.TowerClient
import io.seqera.tower.plugin.dataset.SeqeraDatasetClient
import spock.lang.Specification
/**
* Tests for {@link SeqeraFileSystem} caching and workspace resolution using a mock {@link TowerClient}.
*/
class SeqeraFileSystemTest extends Specification {
private static final String ENDPOINT = 'https://api.example.com'
private TowerClient spyTower() {
def tc = Spy(TowerClient)
tc.@endpoint = ENDPOINT
return tc
}
private static TowerClient.Response ok(String body) {
new TowerClient.Response(200, body)
}
private static String userInfoJson() {
JsonOutput.toJson([user: [id: 42L, userName: 'testuser']])
}
private static String workspacesJson() {
JsonOutput.toJson([orgsAndWorkspaces: [
[orgId: 1L, orgName: 'acme', workspaceId: 10L, workspaceName: 'research', workspaceFullName: 'acme/research'],
[orgId: 1L, orgName: 'acme', workspaceId: 20L, workspaceName: 'dev', workspaceFullName: 'acme/dev'],
[orgId: 2L, orgName: 'other', workspaceId: 30L, workspaceName: 'ws', workspaceFullName: 'other/ws']
]])
}
private SeqeraFileSystem buildFs(TowerClient tc) {
new SeqeraFileSystem(new SeqeraFileSystemProvider(), new SeqeraDatasetClient(tc))
}
// ---- cache loading ----
def "loadOrgWorkspaceCache is called only once across multiple invocations"() {
given:
def tc = spyTower()
final fs = buildFs(tc)
when:
fs.loadOrgWorkspaceCache()
fs.loadOrgWorkspaceCache()
then:
1 * tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
1 * tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
}
def "listOrgNames returns distinct org names from cache"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
when:
def orgs = fs.listOrgNames()
then:
orgs.size() == 2
orgs.contains('acme')
orgs.contains('other')
}
def "listWorkspaceNames returns workspace names for the given org"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
when:
def names = fs.listWorkspaceNames('acme')
then:
names.size() == 2
names.containsAll(['research', 'dev'])
}
// ---- resolveWorkspaceId ----
def "resolveWorkspaceId returns correct ID for known org and workspace"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
when:
def id = fs.resolveWorkspaceId('acme', 'research')
then:
id == 10L
}
def "resolveWorkspaceId throws NoSuchFileException for unknown org"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
when:
fs.resolveWorkspaceId('unknown-org', 'research')
then:
thrown(NoSuchFileException)
}
def "resolveWorkspaceId throws NoSuchFileException for unknown workspace within known org"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
final fs = buildFs(tc)
when:
fs.resolveWorkspaceId('acme', 'no-such-ws')
then:
thrown(NoSuchFileException)
}
// ---- dataset cache ----
def "resolveDatasets populates cache and returns datasets"() {
given:
def tc = spyTower()
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >>
ok(JsonOutput.toJson([datasets: [
[id: 'ds-1', name: 'samples', version: 1L, mediaType: 'text/csv',
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
], totalSize: 1]))
final fs = buildFs(tc)
when:
def datasets = fs.resolveDatasets(10L)
then:
datasets.size() == 1
datasets[0].name == 'samples'
}
def "resolveDatasets returns cached result on second call without extra API request"() {
given:
def tc = spyTower()
final datasetsJson = JsonOutput.toJson([datasets: [
[id: 'ds-1', name: 'samples', version: 1L, mediaType: 'text/csv',
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
], totalSize: 1])
final fs = buildFs(tc)
when:
fs.resolveDatasets(10L)
fs.resolveDatasets(10L)
then:
1 * tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson)
}
def "invalidateDatasetCache forces re-fetch on next resolveDatasets call"() {
given:
def tc = spyTower()
final datasetsJson = JsonOutput.toJson([datasets: [
[id: 'ds-1', name: 'samples', version: 1L, mediaType: 'text/csv',
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
], totalSize: 1])
final fs = buildFs(tc)
when:
fs.resolveDatasets(10L)
fs.invalidateDatasetCache(10L)
fs.resolveDatasets(10L)
then:
2 * tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson)
}
}

View File

@@ -0,0 +1,521 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin.fs
import io.seqera.tower.plugin.dataset.SeqeraDatasetClient
import spock.lang.Specification
/**
* Unit tests for {@link SeqeraPath}.
*/
class SeqeraPathTest extends Specification {
private SeqeraFileSystem mockFs() {
def provider = new SeqeraFileSystemProvider()
def client = Mock(SeqeraDatasetClient)
return new SeqeraFileSystem(provider, client)
}
def "depth 0 - root path"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://')
expect:
path.depth() == 0
path.isDirectory()
!path.isRegularFile()
path.org == null
path.workspace == null
}
def "depth 1 - org path"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme')
expect:
path.depth() == 1
path.isDirectory()
!path.isRegularFile()
path.org == 'acme'
path.workspace == null
}
def "depth 2 - workspace path"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research')
expect:
path.depth() == 2
path.isDirectory()
path.org == 'acme'
path.workspace == 'research'
path.resourceType == null
}
def "depth 3 - resource type path"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets')
expect:
path.depth() == 3
path.isDirectory()
path.org == 'acme'
path.workspace == 'research'
path.resourceType == 'datasets'
path.datasetName == null
}
def "depth 4 - dataset file path"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
expect:
path.depth() == 4
!path.isDirectory()
path.isRegularFile()
path.org == 'acme'
path.workspace == 'research'
path.resourceType == 'datasets'
path.datasetName == 'samples'
path.version == null
}
def "depth 4 - dataset with pinned version"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples@2')
expect:
path.depth() == 4
path.datasetName == 'samples'
path.version == '2'
}
def "toUri round-trip - no version"() {
given:
def fs = mockFs()
def uri = 'seqera://acme/research/datasets/samples'
def path = new SeqeraPath(fs, uri)
expect:
path.toUri().toString() == uri
path.toString() == uri
}
def "toUri round-trip - with version"() {
given:
def fs = mockFs()
def uri = 'seqera://acme/research/datasets/samples@2'
def path = new SeqeraPath(fs, uri)
expect:
path.toUri().toString() == uri
}
def "getParent - depth 4 returns depth 3"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
when:
def parent = path.getParent()
then:
parent.toString() == 'seqera://acme/research/datasets'
(parent as SeqeraPath).depth() == 3
}
def "getParent - depth 3 returns depth 2"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets')
expect:
path.getParent().toString() == 'seqera://acme/research'
}
def "getParent - depth 1 returns depth 0 root"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme')
expect:
path.getParent().toString() == 'seqera://'
}
def "getParent - root returns null"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://')
expect:
path.getParent() == null
}
def "resolve - appends segment to workspace"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research')
when:
def resolved = path.resolve('datasets')
then:
resolved.toString() == 'seqera://acme/research/datasets'
(resolved as SeqeraPath).depth() == 3
}
def "resolve - appends dataset name to resource type"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets')
when:
def resolved = path.resolve('my-dataset')
then:
resolved.toString() == 'seqera://acme/research/datasets/my-dataset'
}
def "resolve - dataset name with version"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets')
when:
def resolved = path.resolve('samples@3')
then:
(resolved as SeqeraPath).datasetName == 'samples'
(resolved as SeqeraPath).version == '3'
}
def "equality and hashCode"() {
given:
def fs = mockFs()
def p1 = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
def p2 = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
def p3 = new SeqeraPath(fs, 'seqera://acme/research/datasets/other')
expect:
p1 == p2
p1.hashCode() == p2.hashCode()
p1 != p3
}
def "isAbsolute always true"() {
given:
def fs = mockFs()
expect:
new SeqeraPath(fs, 'seqera://acme').isAbsolute()
new SeqeraPath(fs, 'seqera://').isAbsolute()
}
def "getNameCount equals depth"() {
given:
def fs = mockFs()
expect:
new SeqeraPath(fs, 'seqera://').nameCount == 0
new SeqeraPath(fs, 'seqera://acme').nameCount == 1
new SeqeraPath(fs, 'seqera://acme/research/datasets/samples').nameCount == 4
}
// ---- relativize ----
def "relativize returns correct relative path string"() {
given:
def fs = mockFs()
expect:
new SeqeraPath(fs, base).relativize(new SeqeraPath(fs, other)).toString() == expected
where:
base | other | expected
'seqera://acme' | 'seqera://acme/research' | 'research'
'seqera://acme/research' | 'seqera://acme/research/datasets' | 'datasets'
'seqera://acme/research' | 'seqera://acme/research/datasets/samples' | 'datasets/samples'
'seqera://acme/research/datasets' | 'seqera://acme/research/datasets/samples' | 'samples'
'seqera://acme/research/datasets/samples' | 'seqera://acme/research/datasets/samples' | ''
}
def "relativize result round-trips through resolve"() {
given:
def fs = mockFs()
def base = new SeqeraPath(fs, 'seqera://acme/research')
def target = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
when:
def rel = base.relativize(target)
def restored = base.resolve(rel)
then:
rel.toString() == 'datasets/samples'
!rel.isAbsolute()
restored == target
}
def "relativize produces '..' segments for upward traversal"() {
given:
def fs = mockFs()
expect:
new SeqeraPath(fs, base).relativize(new SeqeraPath(fs, other)).toString() == expected
where:
base | other | expected
'seqera://acme/research' | 'seqera://acme/dev' | '../dev'
'seqera://acme/research/datasets' | 'seqera://acme/dev' | '../../dev'
'seqera://acme' | 'seqera://other' | '../other'
'seqera://acme/ws1' | 'seqera://acme/ws2' | '../ws2'
'seqera://acme/research/datasets/samples' | 'seqera://acme/research/datasets/other' | '../other'
}
// ---- multi-segment resolve ----
def "resolve with multi-segment string builds correct path"() {
given:
def fs = mockFs()
def base = new SeqeraPath(fs, 'seqera://acme/research')
expect:
base.resolve('datasets/samples').toString() == 'seqera://acme/research/datasets/samples'
base.resolve('datasets').toString() == 'seqera://acme/research/datasets'
}
def "resolve with absolute seqera URI returns that URI"() {
given:
def fs = mockFs()
def base = new SeqeraPath(fs, 'seqera://acme/research')
def absolute = 'seqera://other/ws/datasets/report'
expect:
base.resolve(absolute).toString() == absolute
}
def "isAbsolute is false for relative paths produced by relativize"() {
given:
def fs = mockFs()
def rel = new SeqeraPath(fs, 'seqera://acme').relativize(new SeqeraPath(fs, 'seqera://acme/research'))
expect:
!rel.isAbsolute()
rel.toString() == 'research'
}
// ---- getFileName ----
def "getFileName returns relative path for each depth"() {
given:
def fs = mockFs()
expect:
new SeqeraPath(fs, 'seqera://').getFileName() == null
new SeqeraPath(fs, 'seqera://acme').getFileName().toString() == 'acme'
!new SeqeraPath(fs, 'seqera://acme').getFileName().isAbsolute()
new SeqeraPath(fs, 'seqera://acme/research').getFileName().toString() == 'research'
new SeqeraPath(fs, 'seqera://acme/research/datasets').getFileName().toString() == 'datasets'
new SeqeraPath(fs, 'seqera://acme/research/datasets/samples').getFileName().toString() == 'samples'
new SeqeraPath(fs, 'seqera://acme/research/datasets/samples@2').getFileName().toString() == 'samples@2'
}
def "getFileName is not absolute (uses relative constructor)"() {
given:
def fs = mockFs()
def name = new SeqeraPath(fs, 'seqera://acme/research').getFileName()
expect:
!name.isAbsolute()
name.toString() == 'research'
name.getFileSystem() == null
}
// ---- asUri ----
def "asUri - valid full path round-trips"() {
expect:
SeqeraPath.asUri('seqera://acme/research/datasets/samples').toString() == 'seqera://acme/research/datasets/samples'
SeqeraPath.asUri('seqera://acme/research').toString() == 'seqera://acme/research'
}
def "asUri - empty path returns root URI"() {
expect:
SeqeraPath.asUri('seqera://').toString() == 'seqera:///'
}
def "asUri - path starting with dot has dot stripped"() {
expect:
// seqera://. → strips dot → seqera:// → hits empty-path case → seqera:///
SeqeraPath.asUri('seqera://.').toString() == 'seqera:///'
// seqera://./foo/bar → strips dot only (substring from index 10) → seqera:///foo/bar
SeqeraPath.asUri('seqera://./foo/bar').toString() == 'seqera://foo/bar'
}
def "asUri - triple slash path throws IllegalArgumentException"() {
when:
SeqeraPath.asUri('seqera:///something')
then:
thrown(IllegalArgumentException)
}
def "asUri - missing protocol prefix throws IllegalArgumentException"() {
when:
SeqeraPath.asUri('s3://bucket/key')
then:
thrown(IllegalArgumentException)
}
def "asUri - null or empty throws IllegalArgumentException"() {
when:
SeqeraPath.asUri(null)
then:
thrown(IllegalArgumentException)
when:
SeqeraPath.asUri('')
then:
thrown(IllegalArgumentException)
}
// ---- startsWith ----
def "startsWith - same path returns true"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
expect:
path.startsWith(new SeqeraPath(fs, 'seqera://acme/research/datasets/samples'))
}
def "startsWith - prefix path returns true"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
expect:
path.startsWith(new SeqeraPath(fs, 'seqera://acme'))
path.startsWith(new SeqeraPath(fs, 'seqera://acme/research'))
path.startsWith(new SeqeraPath(fs, 'seqera://'))
}
def "startsWith - component-wise not substring"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme-corp/research/datasets/samples')
expect: 'acme is a substring prefix of acme-corp but not a component prefix'
!path.startsWith(new SeqeraPath(fs, 'seqera://acme'))
}
def "startsWith - longer path returns false"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme')
expect:
!path.startsWith(new SeqeraPath(fs, 'seqera://acme/research'))
}
def "startsWith with string"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
expect:
path.startsWith('seqera://acme')
!path.startsWith('seqera://acm')
}
// ---- endsWith ----
def "endsWith - absolute path requires exact match"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
expect:
path.endsWith(new SeqeraPath(fs, 'seqera://acme/research/datasets/samples'))
!path.endsWith(new SeqeraPath(fs, 'seqera://acme/research'))
}
def "endsWith - relative path matches trailing components"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
expect:
path.endsWith(new SeqeraPath('samples'))
path.endsWith(new SeqeraPath('datasets/samples'))
!path.endsWith(new SeqeraPath('other'))
}
def "endsWith - component-wise not substring"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/my-samples')
expect: 'samples is a substring suffix of my-samples but not a component match'
!path.endsWith(new SeqeraPath('samples'))
}
// ---- iterator ----
def "iterator returns relative name components"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
when:
def parts = path.iterator().collect { it.toString() }
then:
parts == ['acme', 'research', 'datasets', 'samples']
path.iterator().every { !it.isAbsolute() }
}
def "iterator on root returns empty"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://')
expect:
!path.iterator().hasNext()
}
def "iterator on org returns single element"() {
given:
def fs = mockFs()
def path = new SeqeraPath(fs, 'seqera://acme')
when:
def parts = path.iterator().collect { it.toString() }
then:
parts == ['acme']
}
}

View File

@@ -0,0 +1,932 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.tower.plugin.launch
import io.seqera.http.HxClient
import io.seqera.tower.plugin.TowerClient
import nextflow.cli.CmdLaunch
import nextflow.exception.AbortOperationException
import org.junit.Rule
import spock.lang.Specification
import spock.lang.TempDir
import test.OutputCapture
import java.nio.file.Files
import java.nio.file.Path
/**
* Test LaunchCommandImpl functionality
*
* @author Jorge Ejarque <jorge.ejarque@seqera.io>
*/
class LaunchCommandImplTest extends Specification {
@Rule
OutputCapture capture = new OutputCapture()
@TempDir
Path tempDir
// ===== Pipeline Validation Tests =====
def 'should reject local file path'() {
given:
def cmd = new LaunchCommandImpl()
when:
cmd.validateAndResolvePipeline('/path/to/local/workflow')
then:
def ex = thrown(AbortOperationException)
ex.message.contains('Local file paths are not supported')
}
def 'should reject relative file path with ./'() {
given:
def cmd = new LaunchCommandImpl()
when:
cmd.validateAndResolvePipeline('./local/workflow')
then:
def ex = thrown(AbortOperationException)
ex.message.contains('Local file paths are not supported')
}
def 'should reject relative file path with ../'() {
given:
def cmd = new LaunchCommandImpl()
when:
cmd.validateAndResolvePipeline('../local/workflow')
then:
def ex = thrown(AbortOperationException)
ex.message.contains('Local file paths are not supported')
}
def 'should accept remote repository URL'() {
given:
def cmd = Spy(LaunchCommandImpl)
and:
cmd.resolvePipelineUrl(_) >> 'https://github.com/org/repo'
when:
def result = cmd.validateAndResolvePipeline('https://github.com/org/repo')
then:
result == 'https://github.com/org/repo'
}
def 'should accept github short name'() {
given:
def cmd = Spy(LaunchCommandImpl)
and:
cmd.resolvePipelineUrl(_) >> 'https://github.com/nf-core/rnaseq'
when:
def result = cmd.validateAndResolvePipeline('nf-core/rnaseq')
then:
result == 'https://github.com/nf-core/rnaseq'
}
def 'should identify local path correctly'() {
given:
def cmd = new LaunchCommandImpl()
expect:
cmd.isLocalPath('/absolute/path') == true
cmd.isLocalPath('./relative/path') == true
cmd.isLocalPath('../parent/path') == true
cmd.isLocalPath('C:\\windows\\path') == true
cmd.isLocalPath('remote/repo') == false
cmd.isLocalPath('https://github.com/org/repo') == false
}
// ===== Parameter Parsing Tests =====
def 'should parse simple parameters'() {
given:
def cmd = new LaunchCommandImpl()
def params = ['input': 'data.csv', 'output': 'results/']
when:
def paramsText = cmd.buildParamsText(params, null)
then:
paramsText != null
paramsText.contains('"input"')
paramsText.contains('data.csv')
paramsText.contains('"output"')
paramsText.contains('results/')
}
def 'should parse nested parameters with dot notation'() {
given:
def cmd = new LaunchCommandImpl()
def params = ['genome.fasta': 'hg38.fa', 'genome.index': 'hg38.idx']
when:
def paramsText = cmd.buildParamsText(params, null)
then:
paramsText != null
paramsText.contains('"genome"')
paramsText.contains('"fasta"')
paramsText.contains('hg38.fa')
paramsText.contains('"index"')
paramsText.contains('hg38.idx')
}
def 'should parse boolean parameters'() {
given:
def cmd = new LaunchCommandImpl()
def params = ['verbose': 'true', 'skip': 'false']
when:
def paramsText = cmd.buildParamsText(params, null)
then:
paramsText != null
paramsText.contains('"verbose":true')
paramsText.contains('"skip":false')
}
def 'should parse numeric parameters'() {
given:
def cmd = new LaunchCommandImpl()
def params = ['threads': '8', 'memory': '16.5', 'size': '1000000']
when:
def paramsText = cmd.buildParamsText(params, null)
then:
paramsText != null
paramsText.contains('"threads":8')
paramsText.contains('"memory":16.5')
paramsText.contains('"size":1000000')
}
def 'should parse parameters from JSON file'() {
given:
def cmd = new LaunchCommandImpl()
def paramsFile = tempDir.resolve('params.json')
Files.writeString(paramsFile, '{"input": "data.csv", "output": "results/"}')
when:
def paramsText = cmd.buildParamsText([:], paramsFile.toString())
then:
paramsText != null
paramsText.contains('"input"')
paramsText.contains('data.csv')
}
def 'should parse parameters from YAML file'() {
given:
def cmd = new LaunchCommandImpl()
def paramsFile = tempDir.resolve('params.yml')
Files.writeString(paramsFile, 'input: data.csv\noutput: results/')
when:
def paramsText = cmd.buildParamsText([:], paramsFile.toString())
then:
paramsText != null
paramsText.contains('"input"')
paramsText.contains('data.csv')
}
def 'should merge CLI params with params file, CLI taking precedence'() {
given:
def cmd = new LaunchCommandImpl()
def paramsFile = tempDir.resolve('params.json')
Files.writeString(paramsFile, '{"input": "file.csv", "output": "old/"}')
def params = ['output': 'new/']
when:
def paramsText = cmd.buildParamsText(params, paramsFile.toString())
then:
paramsText != null
paramsText.contains('"input"')
paramsText.contains('file.csv')
paramsText.contains('"output"')
paramsText.contains('new/')
}
def 'should reject invalid params file extension'() {
given:
def cmd = new LaunchCommandImpl()
def paramsFile = tempDir.resolve('params.txt')
Files.writeString(paramsFile, 'input: data.csv')
when:
cmd.buildParamsText([:], paramsFile.toString())
then:
thrown(AbortOperationException)
}
def 'should handle missing params file'() {
given:
def cmd = new LaunchCommandImpl()
when:
cmd.buildParamsText([:], '/nonexistent/params.json')
then:
thrown(AbortOperationException)
}
def 'should return null when no parameters provided'() {
given:
def cmd = new LaunchCommandImpl()
when:
def paramsText = cmd.buildParamsText([:], null)
then:
paramsText == null
}
def 'should convert kebab-case to camelCase in parameter names'() {
given:
def cmd = new LaunchCommandImpl()
def params = ['max-memory': '16GB', 'output-dir': 'results/']
when:
def paramsText = cmd.buildParamsText(params, null)
then:
paramsText != null
paramsText.contains('"maxMemory"')
paramsText.contains('"outputDir"')
}
def 'should handle escaped dots in parameter names'() {
given:
def cmd = new LaunchCommandImpl()
def params = ['file\\.name': 'test.csv']
when:
def paramsText = cmd.buildParamsText(params, null)
then:
paramsText != null
paramsText.contains('"file.name"')
}
// ===== Config File Tests =====
def 'should read config file content'() {
given:
def cmd = new LaunchCommandImpl()
def configFile = tempDir.resolve('test.config')
Files.writeString(configFile, 'process.cpus = 8\nprocess.memory = "16 GB"')
when:
def configText = cmd.buildConfigText([configFile.toString()])
then:
configText != null
configText.contains('process.cpus = 8')
configText.contains('process.memory = "16 GB"')
}
def 'should concatenate multiple config files'() {
given:
def cmd = new LaunchCommandImpl()
def config1 = tempDir.resolve('config1.config')
def config2 = tempDir.resolve('config2.config')
Files.writeString(config1, 'process.cpus = 8')
Files.writeString(config2, 'process.memory = "16 GB"')
when:
def configText = cmd.buildConfigText([config1.toString(), config2.toString()])
then:
configText != null
configText.contains('process.cpus = 8')
configText.contains('process.memory = "16 GB"')
}
def 'should return null when no config files provided'() {
given:
def cmd = new LaunchCommandImpl()
when:
def configText = cmd.buildConfigText(null)
then:
configText == null
when:
configText = cmd.buildConfigText([])
then:
configText == null
}
def 'should throw exception for missing config file'() {
given:
def cmd = new LaunchCommandImpl()
when:
cmd.buildConfigText(['/nonexistent/config.config'])
then:
thrown(AbortOperationException)
}
// ===== Launch Context Tests =====
def 'should throw error when no access token configured'() {
given:
def cmd = Spy(LaunchCommandImpl)
cmd.readConfig() >> [:]
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
when:
cmd.initializeLaunchContext(options)
then:
def ex = thrown(AbortOperationException)
ex.message.contains('No authentication found')
ex.message.contains('nextflow auth login')
}
def 'should initialize context with valid config'() {
given:
def config = [
'tower.accessToken': 'test-token',
'tower.endpoint': 'https://api.cloud.seqera.io'
]
def client = Mock(TowerClient){
getUserInfo() >> [name: 'testuser', id: '123']
getUserWorkspaceDetails(_,_) >> null
}
def cmd = Spy(new LaunchCommandImpl())
cmd.createTowerClient(_,_) >> client
cmd.readConfig() >> config
cmd.resolveWorkspaceId(_, _, _, _) >> null
cmd.resolveComputeEnvironment(_,_, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work']
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
when:
def context = cmd.initializeLaunchContext(options)
then:
context.accessToken == 'test-token'
context.apiEndpoint == 'https://api.cloud.seqera.io'
context.userName == 'testuser'
context.computeEnvId == 'ce-123'
context.computeEnvName == 'test-ce'
context.workDir == 's3://bucket/work'
}
def 'should use default endpoint when not configured'() {
given:
def config = ['tower.accessToken': 'test-token']
def client = Mock(TowerClient){
getUserInfo() >> [name: 'testuser', id: '123']
getUserWorkspaceDetails(_, _) >> null
}
def cmd = Spy(new LaunchCommandImpl())
cmd.createTowerClient(_,_) >> client
cmd.readConfig() >> config
cmd.resolveWorkspaceId(_, _, _, _) >> null
cmd.resolveComputeEnvironment(_,_, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work']
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
when:
def context = cmd.initializeLaunchContext(options)
then:
context.apiEndpoint == 'https://api.cloud.seqera.io'
}
def 'should resolve workspace details when workspace ID provided'() {
given:
def config = ['tower.accessToken': 'test-token', 'tower.workspaceId': 12345]
def client = Mock(TowerClient){
getUserInfo() >> [name: 'testuser', id: '123']
getUserWorkspaceDetails(_, _) >> [orgName: 'TestOrg', workspaceName: 'TestWS']
}
def cmd = Spy(new LaunchCommandImpl())
cmd.createTowerClient(_,_) >> client
cmd.readConfig() >> config
cmd.resolveWorkspaceId(_, _, _, _) >> 12345L
cmd.resolveComputeEnvironment(_, _, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work']
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
when:
def context = cmd.initializeLaunchContext(options)
then:
context.workspaceId == 12345L
context.orgName == 'TestOrg'
context.workspaceName == 'TestWS'
}
// ===== Compute Environment Tests =====
def 'should find compute environment by name'() {
given:
def cmd = Spy(LaunchCommandImpl)
def computeEnvs = [
[id: 'ce-1', name: 'primary-ce', primary: true],
[id: 'ce-2', name: 'secondary-ce', primary: false]
]
cmd.listComputeEnvironments(_, _) >> computeEnvs
when:
def result = cmd.findComputeEnv(Mock(TowerClient), 'secondary-ce', null)
then:
result.id == 'ce-2'
result.name == 'secondary-ce'
}
def 'should find primary compute environment when name not provided'() {
given:
def cmd = Spy(LaunchCommandImpl)
def computeEnvs = [
[id: 'ce-1', name: 'primary-ce', primary: true],
[id: 'ce-2', name: 'secondary-ce', primary: false]
]
cmd.listComputeEnvironments(_, _) >> computeEnvs
when:
def result = cmd.findComputeEnv( Mock(TowerClient) ,null, null)
then:
result.id == 'ce-1'
result.name == 'primary-ce'
result.primary == true
}
def 'should return null when compute environment not found'() {
given:
def cmd = Spy(LaunchCommandImpl)
cmd.listComputeEnvironments(_, _) >> []
when:
def result = cmd.findComputeEnv(Mock(TowerClient), 'nonexistent', null)
then:
result == null
}
def 'should throw error when compute environment not found'() {
given:
def cmd = Spy(LaunchCommandImpl)
// Mock findComputeEnv to return null (not found)
cmd.findComputeEnv(_,'nonexistent', null) >> null
when:
cmd.resolveComputeEnvironment(null, 'nonexistent', null, 'token', 'https://api.cloud.seqera.io')
then:
def ex = thrown(AbortOperationException)
ex.message.contains('Compute environment \'nonexistent\' not found')
}
def 'should throw error when no primary compute environment'() {
given:
def cmd = Spy(LaunchCommandImpl)
// Mock findComputeEnv to return null (no primary found)
cmd.createTowerClient(_,_) >> Mock(TowerClient)
cmd.findComputeEnv(_ , null, null) >> null
when:
cmd.resolveComputeEnvironment(null, null, null, 'token', 'https://api.cloud.seqera.io')
then:
def ex = thrown(AbortOperationException)
ex.message.contains('No primary compute environment found')
}
// ===== Work Directory Tests =====
def 'should use CLI work dir when provided'() {
given:
def cmd = new LaunchCommandImpl()
def computeEnvInfo = [workDir: 's3://default/work']
when:
def workDir = cmd.resolveWorkDirectory('s3://custom/work', computeEnvInfo)
then:
workDir == 's3://custom/work'
}
def 'should use compute environment work dir when CLI not provided'() {
given:
def cmd = new LaunchCommandImpl()
def computeEnvInfo = [workDir: 's3://default/work']
when:
def workDir = cmd.resolveWorkDirectory(null, computeEnvInfo)
then:
workDir == 's3://default/work'
}
def 'should throw error when no work dir available'() {
given:
def cmd = new LaunchCommandImpl()
def computeEnvInfo = [:]
when:
cmd.resolveWorkDirectory(null, computeEnvInfo)
then:
def ex = thrown(AbortOperationException)
ex.message.contains('Work directory is required')
}
// ===== Launch Request Building Tests =====
def 'should build basic launch request'() {
given:
def cmd = new LaunchCommandImpl()
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
def context = new LaunchCommandImpl.LaunchContext(
computeEnvId: 'ce-123',
workDir: 's3://bucket/work'
)
when:
def request = cmd.buildLaunchRequestPayload(options, context, 'https://github.com/nf-core/rnaseq', null, null)
then:
request.launch.computeEnvId == 'ce-123'
request.launch.workDir == 's3://bucket/work'
request.launch.pipeline == 'https://github.com/nf-core/rnaseq'
request.launch.resume == false
request.launch.pullLatest == false
request.launch.stubRun == false
}
def 'should include optional parameters in launch request'() {
given:
def cmd = new LaunchCommandImpl()
def options = new CmdLaunch.LaunchOptions(
pipeline: 'nf-core/rnaseq',
runName: 'test-run',
revision: 'main',
profile: 'test',
resume: 'session-id',
latest: true,
stubRun: true,
mainScript: 'main.nf',
entryName: 'workflow1'
)
def context = new LaunchCommandImpl.LaunchContext(
computeEnvId: 'ce-123',
workDir: 's3://bucket/work'
)
when:
def request = cmd.buildLaunchRequestPayload(options, context, 'https://github.com/nf-core/rnaseq',
'{"input":"data.csv"}', 'process.cpus = 8')
then:
request.launch.runName == 'test-run'
request.launch.revision == 'main'
request.launch.configProfiles == 'test'
request.launch.resume == true
request.launch.pullLatest == true
request.launch.stubRun == true
request.launch.mainScript == 'main.nf'
request.launch.entryName == 'workflow1'
request.launch.paramsText == '{"input":"data.csv"}'
request.launch.configText == 'process.cpus = 8'
}
def 'should include workspace and user secrets in launch request'() {
given:
def cmd = new LaunchCommandImpl()
def options = new CmdLaunch.LaunchOptions(
pipeline: 'nf-core/rnaseq',
userSecrets: ['MY_USER_SECRET'],
workspaceSecrets: ['DRAGEN_USERNAME', 'DRAGEN_PASSWORD']
)
def context = new LaunchCommandImpl.LaunchContext(
computeEnvId: 'ce-123',
workDir: 's3://bucket/work'
)
when:
def request = cmd.buildLaunchRequestPayload(options, context, 'https://github.com/nf-core/rnaseq', null, null)
then:
request.launch.userSecrets == ['MY_USER_SECRET'] as Set
request.launch.workspaceSecrets == ['DRAGEN_USERNAME', 'DRAGEN_PASSWORD'] as Set
}
def 'should not include secrets in launch request when none provided'() {
given:
def cmd = new LaunchCommandImpl()
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
def context = new LaunchCommandImpl.LaunchContext(
computeEnvId: 'ce-123',
workDir: 's3://bucket/work'
)
when:
def request = cmd.buildLaunchRequestPayload(options, context, 'https://github.com/nf-core/rnaseq', null, null)
then:
!request.launch.containsKey('userSecrets')
!request.launch.containsKey('workspaceSecrets')
}
// ===== Workflow Status Tests =====
def 'should get color for workflow status'() {
given:
def cmd = new LaunchCommandImpl()
expect:
cmd.getColorForStatus('PENDING') == 'yellow'
cmd.getColorForStatus('SUBMITTED') == 'yellow'
cmd.getColorForStatus('RUNNING') == 'blue'
cmd.getColorForStatus('SUCCEEDED') == 'green'
cmd.getColorForStatus('FAILED') == 'red'
cmd.getColorForStatus('CANCELLED') == 'red'
cmd.getColorForStatus('ABORTED') == 'red'
cmd.getColorForStatus(null) == 'cyan'
}
def 'should get spinner mode for workflow status'() {
given:
def cmd = new LaunchCommandImpl()
expect:
cmd.getSpinnerMode('PENDING', false) == 'waiting'
cmd.getSpinnerMode('SUBMITTED', false) == 'waiting'
cmd.getSpinnerMode('RUNNING', false) == 'running'
cmd.getSpinnerMode('SUCCEEDED', false) == 'succeeded'
cmd.getSpinnerMode('FAILED', false) == 'failed'
cmd.getSpinnerMode('CANCELLED', false) == 'failed'
cmd.getSpinnerMode('ABORTED', false) == 'failed'
cmd.getSpinnerMode(null, false) == 'waiting'
}
def 'should format workflow status'() {
given:
def cmd = new LaunchCommandImpl()
when:
def formatted = cmd.formatWorkflowStatus('RUNNING')
then:
formatted.contains('Workflow status:')
formatted.contains('RUNNING')
}
// ===== Workspace Resolution Tests =====
def 'should use workspace ID from config'() {
given:
def cmd = new LaunchCommandImpl()
def config = ['tower.workspaceId': 12345L]
when:
def workspaceId = cmd.resolveWorkspaceId(config, null, 'token', 'endpoint')
then:
workspaceId == 12345L
}
def 'should lookup workspace by name'() {
given:
def config = [:]
def workspaces = [
[workspaceId: 111, workspaceName: 'ws1'],
[workspaceId: 222, workspaceName: 'ws2']
]
def client = Mock(TowerClient) {
getUserInfo() >> [id: 'user-123']
}
def cmd = Spy(new LaunchCommandImpl())
cmd.createTowerClient(_,_) >> client
cmd.listUserWorkspaces(_, _) >> workspaces
when:
def workspaceId = cmd.resolveWorkspaceId(config, 'ws2', 'token', 'endpoint')
then:
workspaceId == 222
}
def 'should throw error when workspace not found by name'() {
given:
def config = [:]
def client = Mock(TowerClient) {
getUserInfo() >> [id: 'user-123']
}
def cmd = Spy(new LaunchCommandImpl())
cmd.createTowerClient(_,_) >> client
cmd.listUserWorkspaces(_, _, _) >> []
when:
cmd.resolveWorkspaceId(config, 'nonexistent', 'token', 'endpoint')
then:
def ex = thrown(AbortOperationException)
ex.message.contains('Workspace \'nonexistent\' not found')
}
def 'should return null when no workspace specified'() {
given:
def cmd = new LaunchCommandImpl()
def config = [:]
when:
def workspaceId = cmd.resolveWorkspaceId(config, null, 'token', 'endpoint')
then:
workspaceId == null
}
// ===== Launch Result Tests =====
def 'should extract launch result with workflow details'() {
given:
def cmd = new LaunchCommandImpl()
def response = [workflowId: 'wf-123']
def workflowDetails = [
workflow: [
runName: 'test-run',
commitId: 'abc123',
revision: 'main'
]
]
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
def context = new LaunchCommandImpl.LaunchContext(
apiEndpoint: 'https://api.cloud.seqera.io',
userName: 'testuser'
)
when:
def result = cmd.extractLaunchResult(response, workflowDetails, options, 'https://github.com/nf-core/rnaseq', context)
then:
result.workflowId == 'wf-123'
result.runName == 'test-run'
result.commitId == 'abc123'
result.revision == 'main'
result.repository == 'https://github.com/nf-core/rnaseq'
result.trackingUrl.contains('/user/testuser/watch/wf-123/')
}
def 'should extract launch result without workflow details'() {
given:
def cmd = new LaunchCommandImpl()
def response = [workflowId: 'wf-123']
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq', runName: 'custom-run')
def context = new LaunchCommandImpl.LaunchContext(
apiEndpoint: 'https://api.cloud.seqera.io',
userName: 'testuser'
)
when:
def result = cmd.extractLaunchResult(response, null, options, 'https://github.com/nf-core/rnaseq', context)
then:
result.workflowId == 'wf-123'
result.runName == 'custom-run'
result.commitId == 'unknown'
}
def 'should build tracking URL for organization workspace'() {
given:
def cmd = new LaunchCommandImpl()
def response = [workflowId: 'wf-123']
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
def context = new LaunchCommandImpl.LaunchContext(
apiEndpoint: 'https://api.cloud.seqera.io',
userName: 'testuser',
orgName: 'TestOrg',
workspaceName: 'TestWS'
)
when:
def result = cmd.extractLaunchResult(response, null, options, 'https://github.com/nf-core/rnaseq', context)
then:
result.trackingUrl == 'https://cloud.seqera.io/orgs/TestOrg/workspaces/TestWS/watch/wf-123/'
}
// ===== Parameter Value Parsing Tests =====
def 'should parse parameter values correctly'() {
given:
def cmd = new LaunchCommandImpl()
expect:
cmd.parseParamValue('true') == Boolean.TRUE
cmd.parseParamValue('false') == Boolean.FALSE
cmd.parseParamValue('TRUE') == Boolean.TRUE
cmd.parseParamValue('FALSE') == Boolean.FALSE
cmd.parseParamValue('42') == 42
cmd.parseParamValue('3.14') == 3.14
cmd.parseParamValue('text') == 'text'
cmd.parseParamValue(null) == null
}
def 'should convert kebab case to camel case'() {
given:
def cmd = new LaunchCommandImpl()
expect:
cmd.kebabToCamelCase('max-memory') == 'maxMemory'
cmd.kebabToCamelCase('output-dir') == 'outputDir'
cmd.kebabToCamelCase('simple') == 'simple'
cmd.kebabToCamelCase('very-long-param-name') == 'veryLongParamName'
}
// ===== Utility Tests =====
def 'should get web URL from API endpoint'() {
given:
def cmd = new LaunchCommandImpl()
expect:
cmd.getWebUrlFromApiEndpoint('https://api.cloud.seqera.io') == 'https://cloud.seqera.io'
cmd.getWebUrlFromApiEndpoint('https://cloud.seqera.io/api') == 'https://cloud.seqera.io'
cmd.getWebUrlFromApiEndpoint('https://custom.example.com') == 'https://custom.example.com'
}
// ===== Data Class Tests =====
def 'should create LaunchContext with all fields'() {
when:
def context = new LaunchCommandImpl.LaunchContext(
accessToken: 'token',
apiEndpoint: 'https://api.cloud.seqera.io',
userName: 'testuser',
workspaceId: 12345L,
orgName: 'TestOrg',
workspaceName: 'TestWS',
computeEnvId: 'ce-123',
computeEnvName: 'test-ce',
workDir: 's3://bucket/work'
)
then:
context.accessToken == 'token'
context.apiEndpoint == 'https://api.cloud.seqera.io'
context.userName == 'testuser'
context.workspaceId == 12345L
context.orgName == 'TestOrg'
context.workspaceName == 'TestWS'
context.computeEnvId == 'ce-123'
context.computeEnvName == 'test-ce'
context.workDir == 's3://bucket/work'
}
def 'should create WorkflowLaunchResult with all fields'() {
when:
def result = new LaunchCommandImpl.WorkflowLaunchResult(
workflowId: 'wf-123',
runName: 'test-run',
commitId: 'abc123',
revision: 'main',
repository: 'https://github.com/org/repo',
trackingUrl: 'https://cloud.seqera.io/watch/wf-123'
)
then:
result.workflowId == 'wf-123'
result.runName == 'test-run'
result.commitId == 'abc123'
result.revision == 'main'
result.repository == 'https://github.com/org/repo'
result.trackingUrl == 'https://cloud.seqera.io/watch/wf-123'
}
}