Skip to content
22 changes: 20 additions & 2 deletions .github/workflows/execd-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,28 @@ jobs:
#
make multi-build

- name: Run tests
- name: Run tests with coverage
run: |
cd components/execd
make test
go test -v -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./pkg/...

- name: Calculate coverage and generate summary
id: coverage
run: |
cd components/execd
# Extract total coverage percentage
TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
echo "total_coverage=$TOTAL_COVERAGE" >> $GITHUB_OUTPUT

# Generate GitHub Actions job summary
echo "## 📊 execd Test Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total Line Coverage:** $TOTAL_COVERAGE" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Coverage report generated for commit \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*Coverage targets: Core packages >80%, API layer >70%*" >> $GITHUB_STEP_SUMMARY

smoke:
strategy:
Expand Down
138 changes: 128 additions & 10 deletions components/execd/pkg/runtime/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) {
kernel := &jupyterKernel{
kernelID: session.Kernel.ID,
client: client,
language: req.Language,
}
c.storeJupyterKernel(session.ID, kernel)

Expand All @@ -63,6 +64,70 @@ func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) {
return session.ID, nil
}

func (c *Controller) DeleteContext(session string) error {
return c.deleteSessionAndCleanup(session)
}

func (c *Controller) GetContext(session string) CodeContext {
kernel := c.getJupyterKernel(session)
return CodeContext{
ID: session,
Language: kernel.language,
}
}

func (c *Controller) ListContext(language string) ([]CodeContext, error) {
switch language {
case Command.String(), BackgroundCommand.String(), SQL.String():
return nil, fmt.Errorf("unsupported language context operation: %s", language)
case "":
return c.listAllContexts()
default:
return c.listLanguageContexts(Language(language))
}
}

func (c *Controller) DeleteLanguageContext(language Language) error {
contexts, err := c.listLanguageContexts(language)
if err != nil {
return err
}

seen := make(map[string]struct{})
for _, context := range contexts {
if _, ok := seen[context.ID]; ok {
continue
}
seen[context.ID] = struct{}{}

if err := c.deleteSessionAndCleanup(context.ID); err != nil {
return fmt.Errorf("error deleting context %s: %w", context.ID, err)
}
}
return nil
}

func (c *Controller) deleteSessionAndCleanup(session string) error {
if c.getJupyterKernel(session) == nil {
return ErrContextNotFound
}

if err := c.jupyterClient().DeleteSession(session); err != nil {
return err
}

c.mu.Lock()
defer c.mu.Unlock()

delete(c.jupyterClientMap, session)
for lang, id := range c.defaultLanguageJupyterSessions {
if id == session {
delete(c.defaultLanguageJupyterSessions, lang)
}
}
return nil
}

func (c *Controller) newContextID() string {
return strings.ReplaceAll(uuid.New().String(), "-", "")
}
Expand Down Expand Up @@ -106,22 +171,14 @@ func (c *Controller) createDefaultLanguageContext(language Language) error {
c.jupyterClientMap[session.ID] = &jupyterKernel{
kernelID: session.Kernel.ID,
client: client,
language: language,
}
return nil
}

// createContext performs the actual context creation workflow.
func (c *Controller) createContext(request CreateContextRequest) (*jupyter.Client, *jupytersession.Session, error) {
httpClient := &http.Client{
Transport: &jupyter.AuthTransport{
Token: c.token,
Base: http.DefaultTransport,
},
}

client := jupyter.NewClient(c.baseURL,
jupyter.WithToken(c.token),
jupyter.WithHTTPClient(httpClient))
client := c.jupyterClient()

kernel, err := c.searchKernel(client, request.Language)
if err != nil {
Expand Down Expand Up @@ -165,3 +222,64 @@ func (c *Controller) storeJupyterKernel(sessionID string, kernel *jupyterKernel)

c.jupyterClientMap[sessionID] = kernel
}

func (c *Controller) jupyterClient() *jupyter.Client {
httpClient := &http.Client{
Transport: &jupyter.AuthTransport{
Token: c.token,
Base: http.DefaultTransport,
},
}

return jupyter.NewClient(c.baseURL,
jupyter.WithToken(c.token),
jupyter.WithHTTPClient(httpClient))
}

func (c *Controller) listAllContexts() ([]CodeContext, error) {
c.mu.RLock()
defer c.mu.RUnlock()

contexts := make([]CodeContext, 0)
for session, kernel := range c.jupyterClientMap {
if kernel != nil {
contexts = append(contexts, CodeContext{
ID: session,
Language: kernel.language,
})
}
}

for language, defaultContext := range c.defaultLanguageJupyterSessions {
contexts = append(contexts, CodeContext{
ID: defaultContext,
Language: language,
})
}

return contexts, nil
}

func (c *Controller) listLanguageContexts(language Language) ([]CodeContext, error) {
c.mu.RLock()
defer c.mu.RUnlock()

contexts := make([]CodeContext, 0)
for session, kernel := range c.jupyterClientMap {
if kernel != nil && kernel.language == language {
contexts = append(contexts, CodeContext{
ID: session,
Language: language,
})
}
}

if defaultContext := c.defaultLanguageJupyterSessions[language]; defaultContext != "" {
contexts = append(contexts, CodeContext{
ID: defaultContext,
Language: language,
})
}

return contexts, nil
}
189 changes: 189 additions & 0 deletions components/execd/pkg/runtime/context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright 2025 Alibaba Group Holding Ltd.
//
// 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 runtime

import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

func TestListContextsAndNewIpynbPath(t *testing.T) {
c := NewController("http://example", "token")
c.jupyterClientMap["session-python"] = &jupyterKernel{language: Python}
c.defaultLanguageJupyterSessions[Go] = "session-go-default"

pyContexts, err := c.listLanguageContexts(Python)
if err != nil {
t.Fatalf("listLanguageContexts returned error: %v", err)
}
if len(pyContexts) != 1 || pyContexts[0].ID != "session-python" || pyContexts[0].Language != Python {
t.Fatalf("unexpected python contexts: %#v", pyContexts)
}

allContexts, err := c.listAllContexts()
if err != nil {
t.Fatalf("listAllContexts returned error: %v", err)
}
if len(allContexts) != 2 {
t.Fatalf("expected two contexts, got %d", len(allContexts))
}

tmpDir := filepath.Join(t.TempDir(), "nested")
path, err := c.newIpynbPath("abc123", tmpDir)
if err != nil {
t.Fatalf("newIpynbPath error: %v", err)
}
if _, statErr := os.Stat(tmpDir); statErr != nil {
t.Fatalf("expected directory to be created: %v", statErr)
}
expected := filepath.Join(tmpDir, "abc123.ipynb")
if path != expected {
t.Fatalf("unexpected ipynb path: got %s want %s", path, expected)
}
}

func TestNewContextID_UniqueAndLength(t *testing.T) {
c := NewController("", "")
id1 := c.newContextID()
id2 := c.newContextID()

if id1 == "" || id2 == "" {
t.Fatalf("expected non-empty ids")
}
if id1 == id2 {
t.Fatalf("expected unique ids, got identical: %s", id1)
}
if len(id1) != 32 || len(id2) != 32 {
t.Fatalf("expected 32-char ids, got %d and %d", len(id1), len(id2))
}
}

func TestNewIpynbPath_ErrorWhenCwdIsFile(t *testing.T) {
c := NewController("", "")
tmpFile := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(tmpFile, []byte("x"), 0o644); err != nil {
t.Fatalf("prepare file: %v", err)
}

if _, err := c.newIpynbPath("abc", tmpFile); err == nil {
t.Fatalf("expected error when cwd is a file")
}
}

func TestListContextUnsupportedLanguage(t *testing.T) {
c := NewController("", "")
_, err := c.ListContext(Command.String())
if err == nil {
t.Fatalf("expected error for command language")
}
if _, err := c.ListContext(BackgroundCommand.String()); err == nil {
t.Fatalf("expected error for background-command language")
}
if _, err := c.ListContext(SQL.String()); err == nil {
t.Fatalf("expected error for sql language")
}
}

func TestDeleteContext_NotFound(t *testing.T) {
c := NewController("", "")
err := c.DeleteContext("missing")
if err == nil {
t.Fatalf("expected ErrContextNotFound")
}
if !errors.Is(err, ErrContextNotFound) {
t.Fatalf("unexpected error: %v", err)
}
}

func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) {
sessionID := "sess-123"

// mock jupyter server that accepts DELETE
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Fatalf("unexpected method: %s", r.Method)
}
if !strings.HasSuffix(r.URL.Path, "/api/sessions/"+sessionID) {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

c := NewController(server.URL, "token")
c.jupyterClientMap[sessionID] = &jupyterKernel{language: Python}
c.defaultLanguageJupyterSessions[Python] = sessionID

if err := c.DeleteContext(sessionID); err != nil {
t.Fatalf("DeleteContext returned error: %v", err)
}

if kernel := c.getJupyterKernel(sessionID); kernel != nil {
t.Fatalf("expected cache to be cleared, found: %+v", kernel)
}
if _, ok := c.defaultLanguageJupyterSessions[Python]; ok {
t.Fatalf("expected default session entry to be removed")
}
}

func TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) {
lang := Python
session1 := "sess-1"
session2 := "sess-2"

// mock jupyter server to accept two deletes
deleteCalls := make(map[string]int)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Fatalf("unexpected method: %s", r.Method)
}
if strings.Contains(r.URL.Path, session1) {
deleteCalls[session1]++
} else if strings.Contains(r.URL.Path, session2) {
deleteCalls[session2]++
} else {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

c := NewController(server.URL, "token")
c.jupyterClientMap[session1] = &jupyterKernel{language: lang}
c.jupyterClientMap[session2] = &jupyterKernel{language: lang}
c.defaultLanguageJupyterSessions[lang] = session2

if err := c.DeleteLanguageContext(lang); err != nil {
t.Fatalf("DeleteLanguageContext returned error: %v", err)
}

if _, ok := c.jupyterClientMap[session1]; ok {
t.Fatalf("expected session1 removed from cache")
}
if _, ok := c.jupyterClientMap[session2]; ok {
t.Fatalf("expected session2 removed from cache")
}
if _, ok := c.defaultLanguageJupyterSessions[lang]; ok {
t.Fatalf("expected default entry removed")
}
if deleteCalls[session1] != 1 || deleteCalls[session2] != 1 {
t.Fatalf("unexpected delete calls: %+v", deleteCalls)
}
}
1 change: 1 addition & 0 deletions components/execd/pkg/runtime/ctrl.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type jupyterKernel struct {
mu sync.Mutex
kernelID string
client *jupyter.Client
language Language
}

type commandKernel struct {
Expand Down
Loading
Loading