diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8dcefff..122baca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,8 +73,21 @@ jobs: echo "TMS_TEST_RUN_ID=$(<${{ env.TEMP_FILE }})" >> $GITHUB_ENV - name: Test run: | + chmod +x ./scripts/get_sync_storage_version.sh + chmod +x ./scripts/get_sync_storage.sh + + export SYNC_STORAGE_VERSION=$(./scripts/get_sync_storage_version.sh) + + # start sync storage before all other processes + ./scripts/get_sync_storage.sh $SYNC_STORAGE_VERSION + nohup .caches/syncstorage-linux-amd64 --testRunId ${{ env.TMS_TEST_RUN_ID }} --port 49152 \ + --baseURL ${{ env.TMS_URL }} --privateToken ${{ env.TMS_PRIVATE_TOKEN }} > service.log 2>&1 & + curl -v http://127.0.0.1:49152/health || true + cd ci_tests - go test ./... || exit 0 + go test -parallel 4 ./... || true + sleep 1 + curl -v http://127.0.0.1:49152/wait-completion?testRunId=${{ env.TMS_TEST_RUN_ID }} || true - name: Validate run: | dotnet test --configuration Debug --no-build --logger:"console;verbosity=detailed" api-validator-dotnet diff --git a/.gitignore b/.gitignore index bc89c7f..244442a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ go.work *tms.config.json examples -go.sum \ No newline at end of file +go.sum +*/build/.caches \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore index 13566b8..ba7d71a 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -6,3 +6,4 @@ # Datasource local storage ignored files /dataSources/ /dataSources.local.xml +go.imports.xml \ No newline at end of file diff --git a/README.md b/README.md index 5b40f8c..41bdb52 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,41 @@ https://github.com/testit-tms/api-client-golang and previous version of adapter +## What's new in v1.0.0? + +- New logic with a fix for test results loading +- Added sync-storage subprocess usage for worker synchronization on port **49152** by defailt. + +### How to run v1.0+ locally? + +You can change nothing, it's full compatible with previous versions of adapters for local run on all OS. + + +### How to run v1.0+ with CI/CD? + +For CI/CD pipelines, we recommend starting the sync-storage instance before the adapter and waiting for its completion within the same job. + +You can see how we implement this [here.](https://github.com/testit-tms/adapters-go/tree/main/.github/workflows/test.yml#82) + +- to get the latest version of sync-storage, please use our [script](https://github.com/testit-tms/adapters-go/tree/main/scripts/curl_last_version.sh) + +- To download a specific version of sync-storage, use our [script](https://github.com/testit-tms/adapters-go/tree/main/scripts/get_sync_storage.sh) and pass the desired version number as the first parameter. Sync-storage will be downloaded as `.caches/syncstorage-linux-amd64` + +1. Create an empty test run using `testit-cli` or use an existing one, and save the `testRunId`. +2. Start **sync-storage** with the correct parameters as a background process (alternatives to nohup can be used). Stream the log output to the `service.log` file: +```bash +nohup .caches/syncstorage-linux-amd64 --testRunId ${{ env.TMS_TEST_RUN_ID }} --port 49152 \ + --baseURL ${{ env.TMS_URL }} --privateToken ${{ env.TMS_PRIVATE_TOKEN }} > service.log 2>&1 & +``` +3. Start the adapter using adapterMode=1 or adapterMode=0 for the selected testRunId. +4. Wait for sync-storage to complete background jobs by calling: +```bash +curl -v http://127.0.0.1:49152/wait-completion?testRunId=${{ env.TMS_TEST_RUN_ID }} || true +``` +5. You can read the sync-storage logs from the service.log file. + + + ## Getting Started ### Installation @@ -33,19 +68,21 @@ go get github.com/testit-tms/adapters-go@ ### Configuration -| Description | File property | Environment variable | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------|--------------------------------------------| -| Location of the TMS instance | url | TMS_URL | -| API secret key [How to getting API secret key?](https://github.com/testit-tms/.github/tree/main/configuration#privatetoken) | privateToken | TMS_PRIVATE_TOKEN | -| ID of project in TMS instance [How to getting project ID?](https://github.com/testit-tms/.github/tree/main/configuration#projectid) | projectId | TMS_PROJECT_ID | -| ID of configuration in TMS instance [How to getting configuration ID?](https://github.com/testit-tms/.github/tree/main/configuration#configurationid) | configurationId | TMS_CONFIGURATION_ID | -| ID of the created test run in TMS instance.
It's necessary for **adapterMode** 1 | testRunId | TMS_TEST_RUN_ID | +| Description | File property | Environment variable | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------|--------------------------------------------| +| Location of the TMS instance | url | TMS_URL | +| API secret key [How to getting API secret key?](https://github.com/testit-tms/.github/tree/main/configuration#privatetoken) | privateToken | TMS_PRIVATE_TOKEN | +| ID of project in TMS instance [How to getting project ID?](https://github.com/testit-tms/.github/tree/main/configuration#projectid) | projectId | TMS_PROJECT_ID | +| ID of configuration in TMS instance [How to getting configuration ID?](https://github.com/testit-tms/.github/tree/main/configuration#configurationid) | configurationId | TMS_CONFIGURATION_ID | +| ID of the created test run in TMS instance.
It's necessary for **adapterMode** 1 | testRunId | TMS_TEST_RUN_ID | | Parameter for specifying the name of test run in TMS instance (**It's optional**). If it is not provided, it is created automatically | testRunName | TMS_TEST_RUN_NAME | -| Adapter mode. Default value - 1. The adapter supports following modes:
1 - in this mode, the adapter sends all results to the test run without filtering or [with filtering CLI](#run-with-filter)
2 - in this mode, the adapter creates a new test run and sends results to the new test run | adapterMode | TMS_ADAPTER_MODE | -| It enables/disables certificate validation (**It's optional**). Default value - true | certValidation | TMS_CERT_VALIDATION | -| Mode of automatic creation test cases (**It's optional**). Default value - false. The adapter supports following modes:
true - in this mode, the adapter will create a test case linked to the created autotest (not to the updated autotest)
false - in this mode, the adapter will not create a test case | automaticCreationTestCases | TMS_AUTOMATIC_CREATION_TEST_CASES | -| Mode of automatic updation links to test cases (**It's optional**). Default value - false. The adapter supports following modes:
true - in this mode, the adapter will update links to test cases
false - in this mode, the adapter will not update link to test cases | automaticUpdationLinksToTestCases | TMS_AUTOMATIC_UPDATION_LINKS_TO_TEST_CASES | -| Enable debug logs (**It's optional**). Default value - false | isDebug | TMS_IS_DEBUG | +| Adapter mode. Default value - 1. The adapter supports following modes:
1 - in this mode, the adapter sends all results to the test run without filtering or [with filtering CLI](#run-with-filter)
2 - in this mode, the adapter creates a new test run and sends results to the new test run | adapterMode | TMS_ADAPTER_MODE | +| It enables/disables certificate validation (**It's optional**). Default value - true | certValidation | TMS_CERT_VALIDATION | +| Mode of automatic creation test cases (**It's optional**). Default value - false. The adapter supports following modes:
true - in this mode, the adapter will create a test case linked to the created autotest (not to the updated autotest)
false - in this mode, the adapter will not create a test case | automaticCreationTestCases | TMS_AUTOMATIC_CREATION_TEST_CASES | +| Mode of automatic updation links to test cases (**It's optional**). Default value - false. The adapter supports following modes:
true - in this mode, the adapter will update links to test cases
false - in this mode, the adapter will not update link to test cases | automaticUpdationLinksToTestCases | TMS_AUTOMATIC_UPDATION_LINKS_TO_TEST_CASES | +| Enable debug logs (**It's optional**). Default value - false | isDebug | TMS_IS_DEBUG | +| Sync storage port (**It's optional, 49152 by default**) | syncStoragePort | TMS_SYNC_STORAGE_PORT | +| Mode of import type selection when launching autotests (**It's optional**). Default value - false. The adapter supports following modes:
true - in this mode, the adapter will create/update each autotest in real time
false - in this mode, the adapter will create/update multiple autotests | importRealtime | TMS_IMPORT_REALTIME | #### File @@ -108,6 +145,23 @@ testit testrun complete --testrun-id $(cat tmp/output.txt) ``` +### Run with parallelism + +Add t.Parallel() to desired test methods: + +```golang +func TestFixture_success(t *testing.T) { + t.Parallel() + ... +``` + +Then run with parallel option: + +```bash +go test -parallel 4 ./... +``` + + ### Run with filter To create filter by autotests you can use the Test IT CLI (use adapterMode "1" for run with filter): diff --git a/ci_tests/before_after_test.go b/ci_tests/before_after_test.go index 8a3a838..b0f7efd 100644 --- a/ci_tests/before_after_test.go +++ b/ci_tests/before_after_test.go @@ -7,6 +7,7 @@ import ( ) func TestFixture_success(t *testing.T) { + t.Parallel() tms.BeforeTest(t, tms.StepMetadata{ Name: "before test", @@ -55,6 +56,7 @@ func TestFixture_success(t *testing.T) { } func TestFixture_failed(t *testing.T) { + t.Parallel() tms.BeforeTest(t, tms.StepMetadata{ Name: "before test", diff --git a/ci_tests/metadata_test.go b/ci_tests/metadata_test.go index 0d40b10..a16b7d5 100644 --- a/ci_tests/metadata_test.go +++ b/ci_tests/metadata_test.go @@ -8,6 +8,7 @@ import ( ) func TestMetadata_without_metadata_success(t *testing.T) { + t.Parallel() tms.Test(t, tms.TestMetadata{}, func() { tms.True(t, true) @@ -15,6 +16,7 @@ func TestMetadata_without_metadata_success(t *testing.T) { } func TestMetadata_without_metadata_failed(t *testing.T) { + t.Parallel() tms.Test(t, tms.TestMetadata{}, func() { diff --git a/config/config.go b/config/config.go index a5aa7c2..39f50af 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,8 @@ type Config struct { AutomaticCreationTestCases bool `json:"automaticCreationTestCases" env:"TMS_AUTOMATIC_CREATION_TEST_CASES" env-default:"false"` AutomaticUpdationLinksToTestCases bool `json:"automaticUpdationLinksToTestCases" env:"TMS_AUTOMATIC_UPDATION_LINKS_TO_TEST_CASES" env-default:"false"` CertValidation bool `json:"certValidation" env:"TMS_CERT_VALIDATION" env-default:"true"` + SyncStoragePort string `json:"syncStoragePort" env:"TMS_SYNC_STORAGE_PORT" env-default:"49152"` + ImportRealtime bool `json:"importRealtime" env:"TMS_IMPORT_REALTIME" env-default:"false"` } func MustLoad() *Config { diff --git a/init.go b/init.go index 194e045..a77d172 100644 --- a/init.go +++ b/init.go @@ -5,16 +5,18 @@ import ( "github.com/jtolds/gls" "github.com/testit-tms/adapters-go/config" + "github.com/testit-tms/adapters-go/syncstorage" "golang.org/x/exp/slog" ) var ( - cfg *config.Config - client *tmsClient - logger *slog.Logger - ctxMgr *gls.ContextManager - testPhaseObjects map[string]*testPhaseContainer + cfg *config.Config + client *tmsClient + logger *slog.Logger + ctxMgr *gls.ContextManager + testPhaseObjects map[string]*testPhaseContainer + syncStorageRunner *syncstorage.Runner ) const ( @@ -36,6 +38,29 @@ func init() { } ctxMgr = gls.NewContextManager() testPhaseObjects = make(map[string]*testPhaseContainer) + + // Initialize Sync Storage + initSyncStorage() +} + +func initSyncStorage() { + testRunID := cfg.TestRunId + if testRunID == "" { + return + } + + syncStorageRunner = syncstorage.NewRunner( + testRunID, + cfg.SyncStoragePort, + cfg.Url, + cfg.Token, + logger, + ) + + if !syncStorageRunner.Start() { + logger.Warn("Failed to start Sync Storage, continuing without it") + syncStorageRunner = nil + } } func callCreateTestRun(client *tmsClient, cfg *config.Config) { diff --git a/scripts/curl_last_version.sh b/scripts/curl_last_version.sh new file mode 100644 index 0000000..cf9c43d --- /dev/null +++ b/scripts/curl_last_version.sh @@ -0,0 +1 @@ +curl -s "https://api.github.com/repos/testit-tms/sync-storage-public/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' \ No newline at end of file diff --git a/scripts/get_sync_storage.sh b/scripts/get_sync_storage.sh new file mode 100644 index 0000000..2a9b338 --- /dev/null +++ b/scripts/get_sync_storage.sh @@ -0,0 +1,7 @@ +SYNC_STORAGE_VERSION=$1 + +mkdir -p .caches + +wget -O .caches/syncstorage-linux-amd64 \ +"https://github.com/testit-tms/sync-storage-public/releases/download/${SYNC_STORAGE_VERSION}/syncstorage-${SYNC_STORAGE_VERSION}-linux_amd64" +chmod +x .caches/syncstorage-linux-amd64 \ No newline at end of file diff --git a/scripts/get_sync_storage_version.sh b/scripts/get_sync_storage_version.sh new file mode 100644 index 0000000..fbd192b --- /dev/null +++ b/scripts/get_sync_storage_version.sh @@ -0,0 +1 @@ +grep 'syncStorageVersion\s*=' syncstorage/runner.go | sed 's/.*= "\([^"]*\)".*/\1/' \ No newline at end of file diff --git a/syncstorage/client.go b/syncstorage/client.go new file mode 100644 index 0000000..f924d38 --- /dev/null +++ b/syncstorage/client.go @@ -0,0 +1,134 @@ +package syncstorage + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client is a simple HTTP client for the Sync Storage service. +type Client struct { + baseURL string + httpClient *http.Client +} + +// NewClient creates a new Sync Storage client. +func NewClient(port string) *Client { + return &Client{ + baseURL: fmt.Sprintf("http://localhost:%s", port), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// RegisterRequest represents a worker registration request. +type RegisterRequest struct { + PID string `json:"pid"` + TestRunID string `json:"testRunId"` +} + +// RegisterResponse represents a worker registration response. +type RegisterResponse struct { + IsMaster bool `json:"is_master"` +} + +// SetWorkerStatusRequest represents a request to set worker status. +type SetWorkerStatusRequest struct { + PID string `json:"pid"` + Status string `json:"status"` + TestRunID string `json:"testRunId"` +} + +// TestResultCutModel represents a cut-down test result for sync storage. +type TestResultCutModel struct { + AutoTestExternalID string `json:"autoTestExternalId"` + StatusCode string `json:"statusCode"` + StartedOn string `json:"startedOn"` +} + +// HealthCheck checks if the sync storage service is running. +func (c *Client) HealthCheck() error { + resp, err := c.httpClient.Get(c.baseURL + "/health") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health check failed with status %d", resp.StatusCode) + } + return nil +} + +// Register registers a worker with the sync storage service. +func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal register request: %w", err) + } + + resp, err := c.httpClient.Post(c.baseURL+"/register", "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to register worker: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("register failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result RegisterResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode register response: %w", err) + } + + return &result, nil +} + +// SetWorkerStatus sets the status of a worker. +func (c *Client) SetWorkerStatus(req SetWorkerStatusRequest) error { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal set worker status request: %w", err) + } + + resp, err := c.httpClient.Post(c.baseURL+"/set_worker_status", "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to set worker status: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("set worker status failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// SendInProgressTestResult sends an in-progress test result to sync storage. +func (c *Client) SendInProgressTestResult(testRunID string, model TestResultCutModel) error { + body, err := json.Marshal(model) + if err != nil { + return fmt.Errorf("failed to marshal test result: %w", err) + } + + url := fmt.Sprintf("%s/in_progress_test_result?testRunId=%s", c.baseURL, testRunID) + resp, err := c.httpClient.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to send in-progress test result: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("send in-progress test result failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} diff --git a/syncstorage/runner.go b/syncstorage/runner.go new file mode 100644 index 0000000..57958f0 --- /dev/null +++ b/syncstorage/runner.go @@ -0,0 +1,349 @@ +package syncstorage + +import ( + "fmt" + "io" + "os" + "net/http" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "golang.org/x/exp/slog" +) + +const ( + syncStorageVersion = "v0.1.21" + syncStorageRepoURL = "https://github.com/testit-tms/sync-storage-public/releases/download/" + defaultPort = "49152" + startupTimeout = 30 * time.Second + startupCheckInterval = 1 * time.Second + postStartupDelay = 2 * time.Second +) + +// Runner manages the lifecycle of the Sync Storage process and worker coordination. +type Runner struct { + testRunID string + port string + baseURL string + privateToken string + + workerPID string + isMaster bool + isAlreadyInProgress bool + isRunning bool + isExternal bool + + process *exec.Cmd + client *Client + logger *slog.Logger + mu sync.Mutex +} + +// NewRunner creates a new SyncStorage runner. +func NewRunner(testRunID, port, baseURL, privateToken string, logger *slog.Logger) *Runner { + if port == "" { + port = defaultPort + } + + workerPID := fmt.Sprintf("worker-%d-%d", os.Getpid(), time.Now().UnixMilli()) + + return &Runner{ + testRunID: testRunID, + port: port, + baseURL: baseURL, + privateToken: privateToken, + workerPID: workerPID, + client: NewClient(port), + logger: logger, + } +} + +// Start starts the Sync Storage service and registers the worker. +func (r *Runner) Start() bool { + r.mu.Lock() + defer r.mu.Unlock() + + r.logger.Info("Starting Sync Storage service") + + if r.isRunning { + r.logger.Info("SyncStorage already running") + return true + } + + // Check if already running externally + if r.client.HealthCheck() == nil { + r.logger.Info("SyncStorage already started externally, connecting") + r.isRunning = true + r.isExternal = true + r.registerWorker() + return true + } + + // Download and start sync-storage + executablePath, err := r.prepareExecutable() + if err != nil { + r.logger.Error("Failed to prepare sync-storage executable", "error", err) + return false + } + + args := []string{} + if r.testRunID != "" { + args = append(args, "--testRunId", r.testRunID) + } + if r.port != "" { + args = append(args, "--port", r.port) + } + if r.baseURL != "" { + args = append(args, "--baseURL", r.baseURL) + } + if r.privateToken != "" { + args = append(args, "--privateToken", r.privateToken) + } + + r.logger.Info("Starting SyncStorage process", "executable", executablePath, "args", args) + + r.process = exec.Command(executablePath, args...) + r.process.Dir = filepath.Dir(executablePath) + + // Capture stdout/stderr for logging + stdout, err := r.process.StdoutPipe() + if err != nil { + r.logger.Error("Failed to create stdout pipe", "error", err) + return false + } + r.process.Stderr = r.process.Stdout + + if err := r.process.Start(); err != nil { + r.logger.Error("Failed to start SyncStorage process", "error", err) + return false + } + + // Read output in background + go r.readOutput(stdout) + + // Wait for startup + if !r.waitForStartup() { + r.logger.Error("SyncStorage failed to start within timeout") + return false + } + + r.isRunning = true + r.logger.Info("SyncStorage started successfully", "port", r.port) + + time.Sleep(postStartupDelay) + r.registerWorker() + + return true +} + +// IsMaster returns whether this worker is the master. +func (r *Runner) IsMaster() bool { + return r.isMaster +} + +// IsAlreadyInProgress returns the in-progress flag state. +func (r *Runner) IsAlreadyInProgress() bool { + return r.isAlreadyInProgress +} + +// SetAlreadyInProgress sets the in-progress flag. +func (r *Runner) SetAlreadyInProgress(v bool) { + r.isAlreadyInProgress = v +} + +// IsRunning returns whether sync storage is running. +func (r *Runner) IsRunning() bool { + return r.isRunning +} + +// TestRunID returns the test run ID. +func (r *Runner) TestRunID() string { + return r.testRunID +} + +// SetTestRunID updates the test run ID. +func (r *Runner) SetTestRunID(id string) { + r.testRunID = id +} + +// SendInProgressTestResult sends test result to sync storage if this worker is master. +func (r *Runner) SendInProgressTestResult(externalID, statusCode, startedOn string) bool { + if !r.isMaster { + r.logger.Debug("Not master worker, skipping send to sync storage") + return false + } + + if r.isAlreadyInProgress { + r.logger.Debug("Test already in progress, skipping duplicate send") + return false + } + + model := TestResultCutModel{ + AutoTestExternalID: externalID, + StatusCode: statusCode, + StartedOn: startedOn, + } + + if err := r.client.SendInProgressTestResult(r.testRunID, model); err != nil { + r.logger.Warn("Failed to send test result to sync storage", "error", err) + return false + } + + r.isAlreadyInProgress = true + r.logger.Debug("Successfully sent test result to sync storage") + return true +} + +// SetWorkerStatus sets the worker status. +func (r *Runner) SetWorkerStatus(status string) { + if !r.isRunning { + return + } + + req := SetWorkerStatusRequest{ + PID: r.workerPID, + Status: status, + TestRunID: r.testRunID, + } + + if err := r.client.SetWorkerStatus(req); err != nil { + r.logger.Error("Failed to set worker status", "error", err) + } else { + r.logger.Info("Successfully set worker status", "status", status) + } +} + +func (r *Runner) registerWorker() { + req := RegisterRequest{ + PID: r.workerPID, + TestRunID: r.testRunID, + } + + resp, err := r.client.Register(req) + if err != nil { + r.logger.Error("Failed to register worker", "error", err) + return + } + + r.isMaster = resp.IsMaster + if r.isMaster { + r.logger.Info("Master worker registered", "pid", r.workerPID) + } else { + r.logger.Info("Worker registered", "pid", r.workerPID) + } +} + +func (r *Runner) waitForStartup() bool { + deadline := time.Now().Add(startupTimeout) + for time.Now().Before(deadline) { + if r.client.HealthCheck() == nil { + return true + } + time.Sleep(startupCheckInterval) + } + return false +} + +func (r *Runner) readOutput(reader io.Reader) { + buf := make([]byte, 4096) + for { + n, err := reader.Read(buf) + if n > 0 { + r.logger.Info("SyncStorage", "output", strings.TrimSpace(string(buf[:n]))) + } + if err != nil { + break + } + } +} + +func (r *Runner) prepareExecutable() (string, error) { + fileName := getExecutableFileName() + cacheDir := filepath.Join("build", ".caches") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create cache dir: %w", err) + } + + targetPath := filepath.Join(cacheDir, fileName) + + if _, err := os.Stat(targetPath); err == nil { + r.logger.Info("Using existing sync-storage executable", "path", targetPath) + if runtime.GOOS != "windows" { + os.Chmod(targetPath, 0o755) + } + absPath, _ := filepath.Abs(targetPath) + return absPath, nil + } + + r.logger.Info("Downloading sync-storage executable") + downloadURL := fmt.Sprintf("%s%s/%s", syncStorageRepoURL, syncStorageVersion, fileName) + + if err := downloadFile(downloadURL, targetPath); err != nil { + return "", fmt.Errorf("failed to download sync-storage: %w", err) + } + + if runtime.GOOS != "windows" { + os.Chmod(targetPath, 0o755) + } + + absPath, _ := filepath.Abs(targetPath) + return absPath, nil +} + +func getExecutableFileName() string { + osName := runtime.GOOS + arch := runtime.GOARCH + + var osPart string + switch osName { + case "windows": + osPart = "windows" + case "darwin": + osPart = "darwin" + case "linux": + osPart = "linux" + default: + panic(fmt.Sprintf("unsupported OS: %s", osName)) + } + + var archPart string + switch arch { + case "amd64": + archPart = "amd64" + case "arm64": + archPart = "arm64" + default: + panic(fmt.Sprintf("unsupported architecture: %s", arch)) + } + + name := fmt.Sprintf("syncstorage-%s-%s_%s", syncStorageVersion, osPart, archPart) + if osName == "windows" { + name += ".exe" + } + return name +} + +func downloadFile(url, targetPath string) error { + resp, err := (&http.Client{Timeout: 60 * time.Second}).Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + f, err := os.Create(targetPath) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, resp.Body) + return err +} diff --git a/syncstorage_integration.go b/syncstorage_integration.go new file mode 100644 index 0000000..eb36323 --- /dev/null +++ b/syncstorage_integration.go @@ -0,0 +1,17 @@ +package tms + +// onRunningStarted notifies sync-storage that test execution has started. +func onRunningStarted() { + if syncStorageRunner == nil || !syncStorageRunner.IsRunning() { + return + } + syncStorageRunner.SetWorkerStatus("in_progress") +} + +// onBlockCompleted notifies sync-storage that a test block has completed. +func onBlockCompleted() { + if syncStorageRunner == nil || !syncStorageRunner.IsRunning() { + return + } + syncStorageRunner.SetWorkerStatus("completed") +} diff --git a/test.go b/test.go index 219b9de..fcebd57 100644 --- a/test.go +++ b/test.go @@ -33,6 +33,9 @@ func Test(t *testing.T, m TestMetadata, f func()) { testPhaseObjects := getCurrentTestPhaseObject(t) testPhaseObjects.test = tr + // Notify sync-storage that test execution started + onRunningStarted() + defer func() { panicObject := recover() tr.completedOn = time.Now() @@ -61,6 +64,9 @@ func Test(t *testing.T, m TestMetadata, f func()) { if id != "" { testPhaseObjects.resultID = id } + + // Notify sync-storage that test block completed + onBlockCompleted() }() if testPhaseObjects.before != nil && testPhaseObjects.before.status == models.Failed { diff --git a/testresult.go b/testresult.go index 0332329..e837269 100644 --- a/testresult.go +++ b/testresult.go @@ -67,6 +67,30 @@ func (tr *TestResult) addTrace(trace string) { func (tr *TestResult) write() string { const op = "tms.TestResult.write" + + // Sync Storage integration: if master and not already in progress, + // send cut result to sync-storage and write with InProgress status + if syncStorageRunner != nil && syncStorageRunner.IsRunning() && + syncStorageRunner.IsMaster() && !syncStorageRunner.IsAlreadyInProgress() { + + startedOnStr := tr.startedOn.UTC().Format(time.RFC3339) + if syncStorageRunner.SendInProgressTestResult(tr.externalId, tr.status, startedOnStr) { + // Write to TMS with InProgress status + originalStatus := tr.status + tr.status = "InProgress" + id, err := client.writeTest(*tr) + if err != nil { + logger.Error("error writing in-progress test result", "error", err, slog.String("op", op)) + // Fallback: restore status and write normally + tr.status = originalStatus + syncStorageRunner.SetAlreadyInProgress(false) + } else { + return id + } + } + } + + // Normal write path id, err := client.writeTest(*tr) if err != nil { logger.Error("error writing test result", "error", err, slog.String("op", op))