diff --git a/README.md b/README.md index f974ec4..400be73 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,12 @@ notte auth status ### 2. Start a Browser Session ```bash -notte sessions start +SESSION_ID=$(notte sessions start -o json | jq -r '.session_id') ``` Sessions are headless by default. Add `--headed` for a visible browser, and -watch it through the `ViewerUrl` in the output. +watch it through the `ViewerUrl` in the output. Save the returned `session_id` +and pass it explicitly to every command that targets the session. ## Commands @@ -103,18 +104,18 @@ response for scripting. ```bash notte sessions list [--page N] [--page-size N] [-a|--all] # List running sessions (-a includes stopped) notte sessions start [flags] # Start a new session -notte sessions status # Get current session status -notte sessions stop # Stop current session -notte sessions cookies # Get all cookies from current session -notte sessions cookies-set --file cookies.json # Set cookies in current session -notte sessions network # View network activity logs -notte sessions replay # Get session replay data -notte sessions workflow-code # Export session steps as Python code -notte sessions viewer # Open session viewer in browser -notte sessions code # Get Python script for session steps +notte sessions status --session-id # Get session status +notte sessions stop --session-id # Stop session +notte sessions cookies --session-id # Get all cookies +notte sessions cookies-set --session-id --file cookies.json +notte sessions network --session-id # View network activity logs +notte sessions replay --session-id # Get session replay data +notte sessions workflow-code --session-id # Export steps as Python workflow code +notte sessions viewer --session-id # Open session viewer in browser +notte sessions code --session-id # Get Python script for session steps ``` -**Note:** When you start a session, it automatically becomes the "current" session. All subsequent commands use this session by default. Use `--session-id ` only when you need to manage multiple sessions simultaneously or reference a specific session. +Every command that targets a session requires `--session-id `. #### Session Start Options @@ -141,34 +142,35 @@ notte sessions start \ ### Page Actions -Interact with pages using simplified commands (requires an active session): +Interact with pages using simplified commands. Every page command requires +`--session-id `: ```bash -notte page observe # Get page state and available actions -notte page scrape --instructions "..." # Scrape content from the page -notte page click "@B3" # Click an element by ID -notte page fill "@I1" "text" # Fill an input field -notte page goto "https://example.com" # Navigate to a URL -notte page back # Go back in history -notte page forward # Go forward in history -notte page scroll-down [amount] # Scroll down the page -notte page scroll-up [amount] # Scroll up -notte page press "Enter" # Press a key -notte page screenshot # Take a screenshot -notte page select "option" # Select dropdown option -notte page check # Check/uncheck checkbox -notte page upload --file # Fill a file input. is a file in your +notte page observe --session-id # Get page state and available actions +notte page scrape --session-id --instructions "..." # Scrape content from the page +notte page click --session-id "@B3" # Click an element by ID +notte page fill --session-id "@I1" "text" # Fill an input field +notte page goto --session-id "https://example.com" # Navigate to a URL +notte page back --session-id # Go back in history +notte page forward --session-id # Go forward in history +notte page scroll-down --session-id [amount] # Scroll down the page +notte page scroll-up --session-id [amount] # Scroll up +notte page press --session-id "Enter" # Press a key +notte page screenshot --session-id # Take a screenshot +notte page select --session-id "option" # Select dropdown option +notte page check --session-id # Check/uncheck checkbox +notte page upload --session-id --file # Fill a file input. is a file in your # uploads store, not a local path - send it with # `notte files upload` first -notte page download # Download by clicking. The file lands in the +notte page download --session-id # Download by clicking. The file lands in the # session store; retrieve it with - # `notte files download --from session` -notte page new-tab # Open URL in new tab -notte page switch-tab # Switch to tab by index -notte page close-tab # Close current tab -notte page reload # Reload page -notte page wait # Wait for duration -notte page captcha-solve # Solve captcha + # `notte files download --from session --session-id ` +notte page new-tab --session-id # Open URL in new tab +notte page switch-tab --session-id # Switch to tab by index +notte page close-tab --session-id # Close current tab +notte page reload --session-id # Reload page +notte page wait --session-id # Wait for duration +notte page captcha-solve --session-id # Solve captcha ``` ### Functions @@ -176,20 +178,19 @@ notte page captcha-solve # Solve captcha ```bash notte functions list [--page N] [--page-size N] [--include-deleted] # List functions notte functions create --file workflow.py # Create a new function -notte functions show # View current function details -notte functions show --function-id # View specific function details (different from current function) -notte functions update --file workflow.py # Update current function code -notte functions delete # Delete current function -notte functions fork # Fork current function to new version -notte functions run # Execute current function -notte functions runs [--page N] [--page-size N] [--running] # List runs for current function (--running = in-flight only) -notte functions run-stop --run-id # Stop a running function execution -notte functions run-metadata --run-id # Get run logs and results -notte functions schedule --cron "0 9 * * *" # Schedule current function -notte functions unschedule # Remove schedule from current function +notte functions show --function-id # View function details +notte functions update --function-id --file workflow.py +notte functions delete --function-id # Delete function +notte functions fork --function-id # Fork function to new version +notte functions run --function-id # Execute function +notte functions runs --function-id [--page N] [--page-size N] [--running] +notte functions run-stop --function-id --run-id +notte functions run-metadata --function-id --run-id +notte functions schedule --function-id --cron "0 9 * * *" +notte functions unschedule --function-id ``` -**Note:** When you create a function, it automatically becomes the "current" function. All subsequent commands use this function by default. Use `--function-id ` only when you need to manage multiple functions simultaneously or reference a specific function. +Every command that targets a function requires `--function-id `. ### Vaults @@ -230,8 +231,8 @@ notte profiles delete --profile-id # Delete a profile notte files upload # Upload a persistent input file notte files list --from uploads # List persistent input files notte files download --from uploads # Download a persistent input file -notte files list --from session [--session-id ] # List files produced by a session -notte files download [--session-id ] # Download a file produced by a session +notte files list --from session --session-id # List files produced by a session +notte files download --from session --session-id # Download a session file ``` ### Utilities @@ -280,17 +281,17 @@ Data goes to stdout, errors and progress to stderr for clean piping. ### Automated Web Scraping Pipeline ```bash -# Start session (automatically becomes the current session) -notte sessions start +# Start a session and capture its ID +SESSION_ID=$(notte sessions start -o json | jq -r '.session_id') # Navigate to page -notte page goto "https://news.ycombinator.com" +notte page goto --session-id "$SESSION_ID" "https://news.ycombinator.com" # Extract structured data -notte page scrape --instructions "Extract top 10 stories with title and URL" +notte page scrape --session-id "$SESSION_ID" --instructions "Extract top 10 stories with title and URL" # Cleanup -notte sessions stop +notte sessions stop --session-id "$SESSION_ID" ``` ### Running a Workflow @@ -322,22 +323,22 @@ notte vaults credentials list --vault-id $VAULT_ID ### Multi-Step Browser Automation ```bash -# Start browser with specific configuration -notte sessions start \ +# Start browser with specific configuration and capture its ID +SESSION_ID=$(notte sessions start -o json \ --browser-type chrome \ --viewport-width 1920 \ - --viewport-height 1080 + --viewport-height 1080 | jq -r '.session_id') # Navigate and interact -notte page goto "https://example.com" -notte page click "#login-button" -notte page fill "#username" "user@example.com" +notte page goto --session-id "$SESSION_ID" "https://example.com" +notte page click --session-id "$SESSION_ID" "#login-button" +notte page fill --session-id "$SESSION_ID" "#username" "user@example.com" -# Get current page state with available actions -notte page observe +# Get page state with available actions +notte page observe --session-id "$SESSION_ID" # Stop when done -notte sessions stop +notte sessions stop --session-id "$SESSION_ID" ``` ### JQ Filtering @@ -383,12 +384,12 @@ For more consistent results, add to your project or global instructions file: Use `notte` for web automation. Run `notte --help` for all commands. Core workflow: -1. `notte sessions start` - Start a browser session -2. `notte page goto ` - Navigate to a URL -3. `notte page observe` - Get interactive elements with IDs (@B1, @B2) -4. `notte page click "@B1"` / `notte page fill "@I1" "text"` - Interact using element IDs -5. `notte page scrape --instructions "..."` - Extract structured data -6. `notte sessions stop` - Clean up when done +1. `SESSION_ID=$(notte sessions start -o json | jq -r '.session_id')` - Start a browser session and capture its ID +2. `notte page goto --session-id "$SESSION_ID" ` - Navigate to a URL +3. `notte page observe --session-id "$SESSION_ID"` - Get interactive elements with IDs (@B1, @B2) +4. `notte page click --session-id "$SESSION_ID" "@B1"` / `notte page fill --session-id "$SESSION_ID" "@I1" "text"` - Interact using element IDs +5. `notte page scrape --session-id "$SESSION_ID" --instructions "..."` - Extract structured data +6. `notte sessions stop --session-id "$SESSION_ID"` - Clean up when done ``` ### Tips @@ -397,7 +398,7 @@ Core workflow: - **Session lifetime**: sessions close after **3 minutes idle** or **15 minutes total** by default. Raise `--idle-timeout-minutes`/`--max-duration-minutes` for anything slow, or the next command fails with `Session closed` - **Element selectors**: If element IDs from `observe` (like `@B1`) don't work, use Playwright selectors: `#id`, `.class`, `button:has-text('Submit')` - **Multiple matches**: Use `>> nth=0` suffix to select the first match: `button:has-text('OK') >> nth=0` -- **Closing modals**: `notte page press "Escape"` reliably dismisses most dialogs +- **Closing modals**: `notte page press --session-id "Escape"` reliably dismisses most dialogs ### Skills Documentation diff --git a/internal/cmd/clear.go b/internal/cmd/clear.go index a6dfc8e..9443e8e 100644 --- a/internal/cmd/clear.go +++ b/internal/cmd/clear.go @@ -10,14 +10,20 @@ import ( "github.com/nottelabs/notte-cli/internal/config" ) -// legacyCurrentAgentFile is retained only so `notte clear` can clean up state -// written by CLI versions that supported agents. -const legacyCurrentAgentFile = "current_agent" +// Legacy current-resource files are retained only so `notte clear` can clean +// up state written by older CLI versions. +var legacyCurrentResourceFiles = []string{ + "current_session", + "current_viewer_url", + "current_agent", + "current_function", + "current_session_expiry", +} var clearCmd = &cobra.Command{ Use: "clear", - Short: "Clear all stored state", - Long: "Clear all locally stored state including current session, viewer URL, and function. This does not affect credentials or settings.", + Short: "Clear legacy stored resource pointers", + Long: "Clear legacy locally stored resource pointers. This does not affect remote resources, credentials, or settings.", RunE: runClear, } @@ -26,34 +32,24 @@ func init() { } func runClear(cmd *cobra.Command, args []string) error { - if err := clearCurrentSession(); err != nil { - return fmt.Errorf("failed to clear current session: %w", err) - } - if err := clearCurrentViewerURL(); err != nil { - return fmt.Errorf("failed to clear current viewer URL: %w", err) - } - if err := clearLegacyCurrentAgent(); err != nil { - return fmt.Errorf("failed to clear legacy current agent: %w", err) - } - if err := clearCurrentFunction(); err != nil { - return fmt.Errorf("failed to clear current function: %w", err) - } - if err := clearCurrentSessionExpiry(); err != nil { - return fmt.Errorf("failed to clear current session expiry: %w", err) + for _, name := range legacyCurrentResourceFiles { + if err := clearLegacyCurrentResource(name); err != nil { + return fmt.Errorf("failed to clear legacy resource pointer %s: %w", name, err) + } } - return PrintResult("Cleared all stored state (session, viewer URL, function, session expiry).", map[string]any{ - "cleared": []string{"session", "viewer_url", "function", "session_expiry"}, + return PrintResult("Cleared legacy stored resource pointers.", map[string]any{ + "cleared": legacyCurrentResourceFiles, "success": true, }) } -func clearLegacyCurrentAgent() error { +func clearLegacyCurrentResource(name string) error { configDir, err := config.Dir() if err != nil { return err } - path := filepath.Join(configDir, legacyCurrentAgentFile) + path := filepath.Join(configDir, name) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } diff --git a/internal/cmd/clear_test.go b/internal/cmd/clear_test.go index 015db60..d9b1e5e 100644 --- a/internal/cmd/clear_test.go +++ b/internal/cmd/clear_test.go @@ -8,7 +8,7 @@ import ( "github.com/nottelabs/notte-cli/internal/config" ) -func TestClearLegacyCurrentAgent(t *testing.T) { +func TestClearLegacyCurrentResource(t *testing.T) { config.SetTestConfigDir(t.TempDir()) t.Cleanup(func() { config.SetTestConfigDir("") }) @@ -19,18 +19,18 @@ func TestClearLegacyCurrentAgent(t *testing.T) { if err := os.MkdirAll(configDir, 0o700); err != nil { t.Fatalf("MkdirAll() error = %v", err) } - agentPath := filepath.Join(configDir, legacyCurrentAgentFile) + agentPath := filepath.Join(configDir, "current_agent") if err := os.WriteFile(agentPath, []byte("agent-123"), 0o600); err != nil { t.Fatalf("WriteFile() error = %v", err) } - if err := clearLegacyCurrentAgent(); err != nil { - t.Fatalf("clearLegacyCurrentAgent() error = %v", err) + if err := clearLegacyCurrentResource("current_agent"); err != nil { + t.Fatalf("clearLegacyCurrentResource() error = %v", err) } if _, err := os.Stat(agentPath); !os.IsNotExist(err) { t.Errorf("legacy current agent file still exists; Stat() error = %v", err) } - if err := clearLegacyCurrentAgent(); err != nil { - t.Errorf("clearLegacyCurrentAgent() should ignore a missing file: %v", err) + if err := clearLegacyCurrentResource("current_agent"); err != nil { + t.Errorf("clearLegacyCurrentResource() should ignore a missing file: %v", err) } } diff --git a/internal/cmd/confirm.go b/internal/cmd/confirm.go index 486194f..932983e 100644 --- a/internal/cmd/confirm.go +++ b/internal/cmd/confirm.go @@ -37,31 +37,6 @@ func ConfirmActionWithIO(in io.Reader, out io.Writer, resource, id string) (bool return response == "y" || response == "yes", nil } -// confirmReplaceSession prompts the user to confirm stopping an existing session before starting a new one. -// Defaults to "no" if user just presses Enter or stdin reaches EOF. -func confirmReplaceSession(id string) (bool, error) { - if skipConfirmation { - return true, nil - } - return confirmReplaceSessionWithIO(os.Stdin, os.Stderr, id) -} - -// confirmReplaceSessionWithIO is the testable version of confirmReplaceSession. -func confirmReplaceSessionWithIO(in io.Reader, out io.Writer, id string) (bool, error) { - if _, err := fmt.Fprintf(out, "Session %s is currently active. A new session will be created either way.\nStop the existing session before starting the new one? [y/N]: ", id); err != nil { - return false, fmt.Errorf("failed to write prompt: %w", err) - } - - reader := bufio.NewReader(in) - response, err := reader.ReadString('\n') - if err != nil && err != io.EOF { - return false, fmt.Errorf("failed to read response: %w", err) - } - - response = strings.TrimSpace(strings.ToLower(response)) - return response == "y" || response == "yes", nil -} - // SetSkipConfirmation sets whether to skip confirmation prompts (for --yes flag). func SetSkipConfirmation(skip bool) { skipConfirmation = skip diff --git a/internal/cmd/confirm_test.go b/internal/cmd/confirm_test.go index 2231c11..3c23eb5 100644 --- a/internal/cmd/confirm_test.go +++ b/internal/cmd/confirm_test.go @@ -61,65 +61,3 @@ func TestConfirmActionWithIO_Errors(t *testing.T) { t.Fatal("expected read error") } } - -func TestConfirmReplaceSession_Skip(t *testing.T) { - SetSkipConfirmation(true) - t.Cleanup(func() { SetSkipConfirmation(false) }) - - ok, err := confirmReplaceSession("sess_123") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !ok { - t.Fatal("expected confirmation to be skipped") - } -} - -func TestConfirmReplaceSessionWithIO_YesNo(t *testing.T) { - tests := []struct { - input string - expected bool - }{ - {"y\n", true}, - {"yes\n", true}, - {"Y\n", true}, - {"\n", false}, // default is no - {"", false}, // non-interactive EOF is also no - {"n\n", false}, - {"no\n", false}, - {"N\n", false}, - {"NO\n", false}, - {"maybe\n", false}, - } - - for _, tt := range tests { - var out bytes.Buffer - ok, err := confirmReplaceSessionWithIO(strings.NewReader(tt.input), &out, "sess_123") - if err != nil { - t.Fatalf("input %q: unexpected error: %v", tt.input, err) - } - if ok != tt.expected { - t.Fatalf("input %q: expected %v, got %v", tt.input, tt.expected, ok) - } - } - - // Verify prompt text - var out bytes.Buffer - _, _ = confirmReplaceSessionWithIO(strings.NewReader("y\n"), &out, "sess_abc") - if !strings.Contains(out.String(), "sess_abc") { - t.Errorf("expected prompt to contain session ID, got %q", out.String()) - } - if !strings.Contains(out.String(), "[y/N]") { - t.Errorf("expected prompt to contain [y/N], got %q", out.String()) - } -} - -func TestConfirmReplaceSessionWithIO_Errors(t *testing.T) { - if _, err := confirmReplaceSessionWithIO(strings.NewReader("y\n"), errWriter{}, "sess_123"); err == nil { - t.Fatal("expected write error") - } - - if _, err := confirmReplaceSessionWithIO(errReader{}, &bytes.Buffer{}, "sess_123"); err == nil { - t.Fatal("expected read error") - } -} diff --git a/internal/cmd/files.go b/internal/cmd/files.go index 1d50843..859cc1f 100644 --- a/internal/cmd/files.go +++ b/internal/cmd/files.go @@ -70,12 +70,12 @@ func init() { filesListCmd.Flags().BoolVar(&filesListUploadsFlag, "uploads", false, "List uploaded files") filesListCmd.Flags().BoolVar(&filesListDownloadsFlag, "downloads", false, "List downloaded files from a session") filesListCmd.Flags().StringVar(&filesListFrom, "from", "", "File source: uploads or session (default session)") - filesListCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + filesListCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required with --from session)") _ = filesListCmd.Flags().MarkDeprecated("uploads", "use --from uploads instead") _ = filesListCmd.Flags().MarkDeprecated("downloads", "use --from session instead") // Download command flags - filesDownloadCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + filesDownloadCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required with --from session)") filesDownloadCmd.Flags().StringVar(&filesDownloadFrom, "from", "", "File source: uploads or session (default session)") filesDownloadCmd.Flags().StringVar(&filesDownloadOutput, "path", "", "Output file path (defaults to current directory)") } @@ -203,6 +203,11 @@ func runFilesList(cmd *cobra.Command, args []string) error { if err != nil { return err } + if source == filesSourceSession { + if err := RequireSessionID(); err != nil { + return err + } + } client, err := GetClient() if err != nil { @@ -243,11 +248,6 @@ func runFilesList(cmd *cobra.Command, args []string) error { return formatter.Print(fileNames) } - // Default: list downloads for a session - if err := RequireSessionID(); err != nil { - return err - } - ctx, cancel := GetContextWithTimeout(cmd.Context()) defer cancel() @@ -275,7 +275,7 @@ func runFilesList(cmd *cobra.Command, args []string) error { if !IsJSONOutput() { fmt.Printf("Downloaded files in session %s:\n", sessionID) - fmt.Println("Fetch locally with: notte files download ") + fmt.Printf("Fetch locally with: notte files download --from session --session-id %s\n", sessionID) fmt.Println() } return formatter.Print(fileNames) diff --git a/internal/cmd/files_test.go b/internal/cmd/files_test.go index 6291fc6..1554874 100644 --- a/internal/cmd/files_test.go +++ b/internal/cmd/files_test.go @@ -12,7 +12,6 @@ import ( "github.com/spf13/cobra" - "github.com/nottelabs/notte-cli/internal/config" "github.com/nottelabs/notte-cli/internal/testutil" ) @@ -253,15 +252,6 @@ func TestRunFilesListDownloads(t *testing.T) { } func TestRunFilesListDownloadsMissingSession(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") // Need API key for GetClient() - env.SetEnv("NOTTE_SESSION_ID", "") // Clear session env var - - // Set up empty config dir (no session file) - tmpDir := t.TempDir() - config.SetTestConfigDir(tmpDir) - t.Cleanup(func() { config.SetTestConfigDir("") }) - origDownloadsFlag := filesListDownloadsFlag origFrom := filesListFrom origSession := sessionID @@ -404,7 +394,6 @@ func TestRunFilesDownload(t *testing.T) { func TestRunFilesDownloadFromUploads(t *testing.T) { env := testutil.SetupTestEnv(t) env.SetEnv("NOTTE_API_KEY", "test-key") - env.SetEnv("NOTTE_SESSION_ID", "") fileServer := testutil.NewMockServer() defer fileServer.Close() @@ -446,14 +435,6 @@ func TestRunFilesDownloadFromUploads(t *testing.T) { } func TestRunFilesDownloadMissingSession(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_SESSION_ID", "") // Clear session env var - - // Set up empty config dir (no session file) - tmpDir := t.TempDir() - config.SetTestConfigDir(tmpDir) - t.Cleanup(func() { config.SetTestConfigDir("") }) - origSession := sessionID origFrom := filesDownloadFrom t.Cleanup(func() { sessionID = origSession }) diff --git a/internal/cmd/flags_test.go b/internal/cmd/flags_test.go index 4e603ef..a26fb37 100644 --- a/internal/cmd/flags_test.go +++ b/internal/cmd/flags_test.go @@ -118,6 +118,65 @@ func TestNoGenericIDFlags(t *testing.T) { } } +func TestResourceTargetCommandsRequireExplicitIDFlags(t *testing.T) { + tests := []struct { + name string + cmd *cobra.Command + flag string + }{ + {"page", pageCmd, "session-id"}, + {"sessions status", sessionsStatusCmd, "session-id"}, + {"sessions stop", sessionsStopCmd, "session-id"}, + {"sessions observe", sessionsObserveCmd, "session-id"}, + {"sessions execute", sessionsExecuteCmd, "session-id"}, + {"sessions scrape", sessionsScrapeCmd, "session-id"}, + {"sessions cookies", sessionsCookiesCmd, "session-id"}, + {"sessions cookies-set", sessionsCookiesSetCmd, "session-id"}, + {"sessions debug", sessionsDebugCmd, "session-id"}, + {"sessions network", sessionsNetworkCmd, "session-id"}, + {"sessions replay", sessionsReplayCmd, "session-id"}, + {"sessions offset", sessionsOffsetCmd, "session-id"}, + {"sessions workflow-code", sessionsWorkflowCodeCmd, "session-id"}, + {"sessions code", sessionsCodeCmd, "session-id"}, + {"sessions viewer", sessionsViewerCmd, "session-id"}, + {"functions show", functionsShowCmd, "function-id"}, + {"functions update", functionsUpdateCmd, "function-id"}, + {"functions delete", functionsDeleteCmd, "function-id"}, + {"functions run", functionsRunCmd, "function-id"}, + {"functions runs", functionsRunsCmd, "function-id"}, + {"functions fork", functionsForkCmd, "function-id"}, + {"functions run-stop", functionsRunStopCmd, "function-id"}, + {"functions run-metadata", functionsRunMetadataCmd, "function-id"}, + {"functions run-metadata-update", functionsRunMetadataUpdateCmd, "function-id"}, + {"functions schedule", functionsScheduleCmd, "function-id"}, + {"functions unschedule", functionsUnscheduleCmd, "function-id"}, + {"vaults update", vaultsUpdateCmd, "vault-id"}, + {"vaults delete", vaultsDeleteCmd, "vault-id"}, + {"vault credentials", vaultsCredentialsCmd, "vault-id"}, + {"personas show", personasShowCmd, "persona-id"}, + {"personas delete", personasDeleteCmd, "persona-id"}, + {"personas emails", personasEmailsCmd, "persona-id"}, + {"personas sms", personasSmsCmd, "persona-id"}, + {"profiles show", profilesShowCmd, "profile-id"}, + {"profiles delete", profilesDeleteCmd, "profile-id"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag := tt.cmd.Flags().Lookup(tt.flag) + if flag == nil { + flag = tt.cmd.PersistentFlags().Lookup(tt.flag) + } + if flag == nil { + t.Fatalf("missing --%s", tt.flag) + } + if _, required := flag.Annotations[cobra.BashCompOneRequiredFlag]; !required { + t.Fatalf("--%s is not marked required", tt.flag) + } + }) + } +} + // TestPaginatedListCommandsHaveFlags ensures all paginated list commands // expose --page, --page-size, and their resource-specific filter flags. func TestPaginatedListCommandsHaveFlags(t *testing.T) { diff --git a/internal/cmd/functions.go b/internal/cmd/functions.go index e0bc503..84c1ed1 100644 --- a/internal/cmd/functions.go +++ b/internal/cmd/functions.go @@ -15,7 +15,6 @@ import ( "github.com/spf13/cobra" "github.com/nottelabs/notte-cli/internal/api" - "github.com/nottelabs/notte-cli/internal/config" ) var ( @@ -36,61 +35,10 @@ var ( functionSecretValue string ) -// GetCurrentFunctionID returns the function ID from flag, env var, or file (in priority order) -func GetCurrentFunctionID() string { - // 1. Check --function-id flag (already in functionID variable if set) - if functionID != "" { - return functionID - } - - // 2. Check NOTTE_FUNCTION_ID env var - if envID := os.Getenv(config.EnvFunctionID); envID != "" { - return envID - } - - // 3. Check current_function file - configDir, err := config.Dir() - if err != nil { - return "" - } - data, err := os.ReadFile(filepath.Join(configDir, config.CurrentFunctionFile)) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -// setCurrentFunction saves the function ID to the current_function file -func setCurrentFunction(id string) error { - configDir, err := config.Dir() - if err != nil { - return err - } - // Ensure directory exists - if err := os.MkdirAll(configDir, 0o700); err != nil { - return err - } - return os.WriteFile(filepath.Join(configDir, config.CurrentFunctionFile), []byte(id), 0o600) -} - -// clearCurrentFunction removes the current_function file -func clearCurrentFunction() error { - configDir, err := config.Dir() - if err != nil { - return err - } - path := filepath.Join(configDir, config.CurrentFunctionFile) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// RequireFunctionID ensures a function ID is available from flag, env, or file +// RequireFunctionID ensures a function ID was supplied explicitly. func RequireFunctionID() error { - functionID = GetCurrentFunctionID() if functionID == "" { - return errors.New("function ID required: use --function-id flag, set NOTTE_FUNCTION_ID env var, or create a function first") + return errors.New("function ID required: use --function-id") } return nil } @@ -269,50 +217,61 @@ func init() { functionsCreateCmd.Flags().BoolVar(&functionsCreateShared, "shared", false, "Make function public") // Show command flags - functionsShowCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsShowCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsShowCmd.MarkFlagRequired("function-id") // Update command flags - functionsUpdateCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsUpdateCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsUpdateCmd.MarkFlagRequired("function-id") functionsUpdateCmd.Flags().StringVar(&functionUpdateFile, "file", "", "Path to updated function file (required)") _ = functionsUpdateCmd.MarkFlagRequired("file") // Delete command flags - functionsDeleteCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsDeleteCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsDeleteCmd.MarkFlagRequired("function-id") // Run command flags - functionsRunCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsRunCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsRunCmd.MarkFlagRequired("function-id") functionsRunCmd.Flags().StringArrayVar(&functionRunVariables, "var", []string{}, "Variable as key=value pair (can be used multiple times)") functionsRunCmd.Flags().StringVar(&functionRunVariablesJSON, "vars", "", "Variables as JSON object string") // Runs command flags - functionsRunsCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsRunsCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsRunsCmd.MarkFlagRequired("function-id") // Fork command flags - functionsForkCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsForkCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsForkCmd.MarkFlagRequired("function-id") // Run-stop command flags - functionsRunStopCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsRunStopCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsRunStopCmd.MarkFlagRequired("function-id") functionsRunStopCmd.Flags().StringVar(&functionRunID, "run-id", "", "Run ID (required)") _ = functionsRunStopCmd.MarkFlagRequired("run-id") // Run-metadata command flags - functionsRunMetadataCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsRunMetadataCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsRunMetadataCmd.MarkFlagRequired("function-id") functionsRunMetadataCmd.Flags().StringVar(&functionRunID, "run-id", "", "Run ID (required)") _ = functionsRunMetadataCmd.MarkFlagRequired("run-id") // Run-metadata-update command flags - functionsRunMetadataUpdateCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsRunMetadataUpdateCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsRunMetadataUpdateCmd.MarkFlagRequired("function-id") functionsRunMetadataUpdateCmd.Flags().StringVar(&functionRunID, "run-id", "", "Run ID (required)") _ = functionsRunMetadataUpdateCmd.MarkFlagRequired("run-id") functionsRunMetadataUpdateCmd.Flags().StringVar(&functionMetadataJSON, "data", "", "JSON metadata, @file, or '-' for stdin") // Schedule command flags - functionsScheduleCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsScheduleCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsScheduleCmd.MarkFlagRequired("function-id") functionsScheduleCmd.Flags().StringVar(&functionCronExpression, "cron", "", "Cron expression (required)") _ = functionsScheduleCmd.MarkFlagRequired("cron") // Unschedule command flags - functionsUnscheduleCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (uses current function if not specified)") + functionsUnscheduleCmd.Flags().StringVar(&functionID, "function-id", "", "Function ID (required)") + _ = functionsUnscheduleCmd.MarkFlagRequired("function-id") // Function secrets command flags functionSecretsSetCmd.Flags().StringVar(&functionSecretValue, "value", "", "Secret value") @@ -427,13 +386,6 @@ func runFunctionsCreate(cmd *cobra.Command, args []string) error { return err } - // Save function ID as current function - if resp.JSON200 != nil && resp.JSON200.FunctionId != "" { - if err := setCurrentFunction(resp.JSON200.FunctionId); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save current function: %v", err)) - } - } - formatter := GetFormatter() return formatter.Print(resp.JSON200) } @@ -549,15 +501,6 @@ func runFunctionDelete(cmd *cobra.Command, args []string) error { return err } - // Clear current function only if it matches the deleted function - configDir, _ := config.Dir() - if configDir != "" { - data, _ := os.ReadFile(filepath.Join(configDir, config.CurrentFunctionFile)) - if strings.TrimSpace(string(data)) == functionID { - _ = clearCurrentFunction() - } - } - return PrintResult(fmt.Sprintf("Function %s deleted.", functionID), map[string]any{ "id": functionID, "status": "deleted", diff --git a/internal/cmd/functions_test.go b/internal/cmd/functions_test.go index 783dc32..261e4a0 100644 --- a/internal/cmd/functions_test.go +++ b/internal/cmd/functions_test.go @@ -4,13 +4,11 @@ import ( "context" "encoding/json" "os" - "path/filepath" "strings" "testing" "github.com/spf13/cobra" - "github.com/nottelabs/notte-cli/internal/config" "github.com/nottelabs/notte-cli/internal/testutil" ) @@ -649,432 +647,29 @@ func TestRunFunctionUnschedule(t *testing.T) { } } -// Tests for function ID resolution (file-based tracking) - -func setupFunctionFileTest(t *testing.T) string { - t.Helper() - - // Create a temporary config directory - tmpDir := t.TempDir() - config.SetTestConfigDir(tmpDir) - t.Cleanup(func() { config.SetTestConfigDir("") }) - - return tmpDir -} - -func TestGetCurrentFunctionID_FromFlag(t *testing.T) { - origID := functionID - functionID = "flag_function" - t.Cleanup(func() { functionID = origID }) - - got := GetCurrentFunctionID() - if got != "flag_function" { - t.Errorf("GetCurrentFunctionID() = %q, want %q", got, "flag_function") - } -} - -func TestGetCurrentFunctionID_FromEnvVar(t *testing.T) { - origID := functionID - functionID = "" - t.Cleanup(func() { functionID = origID }) - - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_FUNCTION_ID", "env_function") - - got := GetCurrentFunctionID() - if got != "env_function" { - t.Errorf("GetCurrentFunctionID() = %q, want %q", got, "env_function") - } -} - -func TestGetCurrentFunctionID_FromFile(t *testing.T) { +func TestRequireFunctionID_RequiresExplicitID(t *testing.T) { origID := functionID functionID = "" t.Cleanup(func() { functionID = origID }) env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_FUNCTION_ID", "") // Ensure env var is empty - - // Create temp config dir - tmpDir := setupFunctionFileTest(t) - - // Write function file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - if err := os.WriteFile(functionFile, []byte("file_function"), 0o600); err != nil { - t.Fatalf("failed to write function file: %v", err) - } - - got := GetCurrentFunctionID() - if got != "file_function" { - t.Errorf("GetCurrentFunctionID() = %q, want %q", got, "file_function") - } -} - -func TestGetCurrentFunctionID_Priority(t *testing.T) { - origID := functionID - t.Cleanup(func() { functionID = origID }) - - env := testutil.SetupTestEnv(t) - tmpDir := setupFunctionFileTest(t) - - // Create function file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - if err := os.WriteFile(functionFile, []byte("file_function"), 0o600); err != nil { - t.Fatalf("failed to write function file: %v", err) - } - - // Test: flag > env > file - functionID = "flag_function" env.SetEnv("NOTTE_FUNCTION_ID", "env_function") - got := GetCurrentFunctionID() - if got != "flag_function" { - t.Errorf("flag should have highest priority: got %q, want %q", got, "flag_function") - } - - // Test: env > file - functionID = "" - got = GetCurrentFunctionID() - if got != "env_function" { - t.Errorf("env should have priority over file: got %q, want %q", got, "env_function") - } - - // Test: file as fallback - env.SetEnv("NOTTE_FUNCTION_ID", "") - got = GetCurrentFunctionID() - if got != "file_function" { - t.Errorf("file should be fallback: got %q, want %q", got, "file_function") - } -} - -func TestSetCurrentFunction(t *testing.T) { - tmpDir := setupFunctionFileTest(t) - - err := setCurrentFunction("test_function_id") - if err != nil { - t.Fatalf("setCurrentFunction() error = %v", err) - } - - // Verify file was created - configDir := filepath.Join(tmpDir, config.ConfigDirName) - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - - data, err := os.ReadFile(functionFile) - if err != nil { - t.Fatalf("failed to read function file: %v", err) - } - - if string(data) != "test_function_id" { - t.Errorf("function file content = %q, want %q", string(data), "test_function_id") - } -} - -func TestClearCurrentFunction(t *testing.T) { - tmpDir := setupFunctionFileTest(t) - - // First create a function file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - if err := os.WriteFile(functionFile, []byte("test_function"), 0o600); err != nil { - t.Fatalf("failed to write function file: %v", err) - } - - // Clear it - err := clearCurrentFunction() - if err != nil { - t.Fatalf("clearCurrentFunction() error = %v", err) - } - - // Verify file was removed - if _, err := os.Stat(functionFile); !os.IsNotExist(err) { - t.Error("function file should have been removed") - } -} - -func TestClearCurrentFunction_NoFile(t *testing.T) { - _ = setupFunctionFileTest(t) - - // Should not error when file doesn't exist - err := clearCurrentFunction() - if err != nil { - t.Errorf("clearCurrentFunction() should not error when file doesn't exist: %v", err) - } -} - -func TestRequireFunctionID_NoFunction(t *testing.T) { - origID := functionID - functionID = "" - t.Cleanup(func() { functionID = origID }) - - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_FUNCTION_ID", "") - _ = setupFunctionFileTest(t) - err := RequireFunctionID() if err == nil { - t.Fatal("RequireFunctionID() should error when no function ID available") + t.Fatal("RequireFunctionID() should reject implicit function IDs") } - - expectedMsg := "function ID required" - if !strings.Contains(err.Error(), expectedMsg) { - t.Errorf("error message should contain %q, got %q", expectedMsg, err.Error()) + if !strings.Contains(err.Error(), "use --function-id") { + t.Fatalf("unexpected error: %v", err) } } -func TestRequireFunctionID_FromFile(t *testing.T) { +func TestRequireFunctionID_WithExplicitID(t *testing.T) { origID := functionID - functionID = "" + functionID = "fn_explicit" t.Cleanup(func() { functionID = origID }) - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_FUNCTION_ID", "") - tmpDir := setupFunctionFileTest(t) - - // Create function file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - if err := os.WriteFile(functionFile, []byte("file_function"), 0o600); err != nil { - t.Fatalf("failed to write function file: %v", err) - } - - err := RequireFunctionID() - if err != nil { + if err := RequireFunctionID(); err != nil { t.Fatalf("RequireFunctionID() error = %v", err) } - - if functionID != "file_function" { - t.Errorf("functionID = %q, want %q", functionID, "file_function") - } -} - -func TestFunctionsCreate_SetsCurrentFunction(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupFunctionFileTest(t) - - server.AddResponse("/functions", 200, `{"function_id":"fn_new_123","latest_version":"1","status":"active","created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","versions":["1"]}`) - - tmpFile, err := os.CreateTemp("", "function-*.json") - if err != nil { - t.Fatalf("failed to create temp file: %v", err) - } - if _, err := tmpFile.WriteString(`{"steps":[]}`); err != nil { - t.Fatalf("failed to write temp file: %v", err) - } - if err := tmpFile.Close(); err != nil { - t.Fatalf("failed to close temp file: %v", err) - } - t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) }) - - origFile := functionsCreateFile - origName := functionsCreateName - origDesc := functionsCreateDescription - t.Cleanup(func() { - functionsCreateFile = origFile - functionsCreateName = origName - functionsCreateDescription = origDesc - }) - - functionsCreateFile = tmpFile.Name() - functionsCreateName = "Test Function" - functionsCreateDescription = "Test description" - - origFormat := outputFormat - outputFormat = "json" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.Flags().BoolVar(&functionsCreateShared, "shared", false, "") - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runFunctionsCreate(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify function was saved to file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - - data, err := os.ReadFile(functionFile) - if err != nil { - t.Fatalf("failed to read function file: %v", err) - } - - if string(data) != "fn_new_123" { - t.Errorf("function file content = %q, want %q", string(data), "fn_new_123") - } -} - -func TestFunctionDelete_ClearsCurrentFunction(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupFunctionFileTest(t) - - // Create function file first - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - if err := os.WriteFile(functionFile, []byte(functionIDTest), 0o600); err != nil { - t.Fatalf("failed to write function file: %v", err) - } - - server.AddResponse("/functions/"+functionIDTest, 200, `{"message":"deleted","status":"deleted"}`) - - origID := functionID - functionID = functionIDTest - t.Cleanup(func() { functionID = origID }) - - SetSkipConfirmation(true) - t.Cleanup(func() { SetSkipConfirmation(false) }) - - origFormat := outputFormat - outputFormat = "text" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runFunctionDelete(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify function file was cleared - if _, err := os.Stat(functionFile); !os.IsNotExist(err) { - t.Error("function file should have been removed after delete") - } -} - -func TestFunctionDelete_DifferentFunction_DoesNotClearCurrentFunction(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupFunctionFileTest(t) - - // Create function file with "fn_current" - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - if err := os.WriteFile(functionFile, []byte("fn_current"), 0o600); err != nil { - t.Fatalf("failed to write function file: %v", err) - } - - // Delete a different function "fn_different" - server.AddResponse("/functions/fn_different", 200, `{"message":"deleted","status":"deleted"}`) - - origID := functionID - functionID = "fn_different" - t.Cleanup(func() { functionID = origID }) - - SetSkipConfirmation(true) - t.Cleanup(func() { SetSkipConfirmation(false) }) - - origFormat := outputFormat - outputFormat = "text" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runFunctionDelete(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify function file still contains "fn_current" - data, err := os.ReadFile(functionFile) - if err != nil { - t.Fatalf("function file should still exist: %v", err) - } - if strings.TrimSpace(string(data)) != "fn_current" { - t.Errorf("function file content = %q, want %q", string(data), "fn_current") - } -} - -func TestFunctionShow_UsesCurrentFunction(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupFunctionFileTest(t) - - // Create function file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - functionFile := filepath.Join(configDir, config.CurrentFunctionFile) - if err := os.WriteFile(functionFile, []byte(functionIDTest), 0o600); err != nil { - t.Fatalf("failed to write function file: %v", err) - } - - server.AddResponse("/functions/"+functionIDTest, 200, functionWithLinkJSON()) - - // Clear functionID to test file-based resolution - origID := functionID - functionID = "" - t.Cleanup(func() { functionID = origID }) - - // Clear env var too - env.SetEnv("NOTTE_FUNCTION_ID", "") - - origFormat := outputFormat - outputFormat = "json" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - - stdout, _ := testutil.CaptureOutput(func() { - err := runFunctionShow(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - if stdout == "" { - t.Error("expected output, got empty string") - } } diff --git a/internal/cmd/page.go b/internal/cmd/page.go index 37e0a42..56499fc 100644 --- a/internal/cmd/page.go +++ b/internal/cmd/page.go @@ -146,11 +146,11 @@ var pageCmd = &cobra.Command{ Long: `Execute page actions with a simplified command interface. Use: - notte page click "#btn" - notte page click B3 # element ID (auto-detected) - notte page click @B3 # @-prefix also works (legacy) - notte page fill I1 "hello" - notte page goto "https://example.com"`, + notte page click --session-id "#btn" + notte page click --session-id B3 # element ID (auto-detected) + notte page click --session-id @B3 # @-prefix also works (legacy) + notte page fill --session-id I1 "hello" + notte page goto --session-id "https://example.com"`, } // Element Actions (selector-based) @@ -595,9 +595,9 @@ var pageScreenshotCmd = &cobra.Command{ By default, saves to a temporary directory. Optionally provide a path to save to a specific location. Examples: - notte page screenshot # saves to tmp directory - notte page screenshot screenshot.jpg # saves to specified path - notte page screenshot --output out.jpg # saves to specified path (alt syntax)`, + notte page screenshot --session-id # saves to tmp directory + notte page screenshot --session-id screenshot.jpg # saves to specified path + notte page screenshot --session-id --output out.jpg # saves to specified path (alt syntax)`, Args: cobra.MaximumNArgs(1), RunE: runPageScreenshot, } @@ -690,9 +690,9 @@ var pageEvalJsCmd = &cobra.Command{ The JavaScript code is executed in the context of the page's main frame. Examples: - notte page eval-js "document.title" - notte page eval-js "window.location.href" - notte page eval-js "document.querySelectorAll('a').length"`, + notte page eval-js --session-id "document.title" + notte page eval-js --session-id "window.location.href" + notte page eval-js --session-id "document.querySelectorAll('a').length"`, Args: cobra.ExactArgs(1), RunE: runPageEvalJs, } @@ -785,7 +785,8 @@ func init() { pageCmd.AddCommand(pageEvalJsCmd) // Add --session-id flag to parent command (inherited by all subcommands) - pageCmd.PersistentFlags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + pageCmd.PersistentFlags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = pageCmd.MarkPersistentFlagRequired("session-id") // click flags pageClickCmd.Flags().IntVar(&pageClickTimeout, "timeout", 0, "Timeout in milliseconds") diff --git a/internal/cmd/page_test.go b/internal/cmd/page_test.go index bcce65a..5d12d6b 100644 --- a/internal/cmd/page_test.go +++ b/internal/cmd/page_test.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" - "github.com/nottelabs/notte-cli/internal/config" "github.com/nottelabs/notte-cli/internal/testutil" ) @@ -738,16 +737,11 @@ func TestRunPageFormFill_InvalidJSON(t *testing.T) { func TestPageCommand_NoSessionID(t *testing.T) { env := testutil.SetupTestEnv(t) env.SetEnv("NOTTE_API_KEY", "test-key") - env.SetEnv("NOTTE_SESSION_ID", "") server := testutil.NewMockServer() t.Cleanup(func() { server.Close() }) env.SetEnv("NOTTE_API_URL", server.URL()) - // Use isolated config directory so no current_session file is found - config.SetTestConfigDir(env.TempDir) - t.Cleanup(func() { config.SetTestConfigDir("") }) - // Clear sessionID origID := sessionID sessionID = "" diff --git a/internal/cmd/sessions.go b/internal/cmd/sessions.go index 573a493..bc6b0e0 100644 --- a/internal/cmd/sessions.go +++ b/internal/cmd/sessions.go @@ -11,14 +11,12 @@ import ( "os/exec" "path/filepath" "runtime" - "strings" "sync" "time" "github.com/spf13/cobra" "github.com/nottelabs/notte-cli/internal/api" - "github.com/nottelabs/notte-cli/internal/config" ) // Manual flags for proxies and extra headers (union types not auto-generated) @@ -44,137 +42,10 @@ var ( sessionReplayOutput string ) -// GetCurrentSessionID returns the session ID from flag, env var, or file (in priority order) -func GetCurrentSessionID() string { - // 1. Check --session-id flag (already in sessionID variable if set) - if sessionID != "" { - return sessionID - } - - // 2. Check NOTTE_SESSION_ID env var - if envID := os.Getenv(config.EnvSessionID); envID != "" { - return envID - } - - // 3. Check current_session file - configDir, err := config.Dir() - if err != nil { - return "" - } - data, err := os.ReadFile(filepath.Join(configDir, config.CurrentSessionFile)) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -// setCurrentSession saves the session ID to the current_session file -func setCurrentSession(id string) error { - configDir, err := config.Dir() - if err != nil { - return err - } - // Ensure directory exists - if err := os.MkdirAll(configDir, 0o700); err != nil { - return err - } - return os.WriteFile(filepath.Join(configDir, config.CurrentSessionFile), []byte(id), 0o600) -} - -// clearCurrentSession removes the current_session file -func clearCurrentSession() error { - configDir, err := config.Dir() - if err != nil { - return err - } - path := filepath.Join(configDir, config.CurrentSessionFile) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// setCurrentViewerURL saves the viewer URL to the current_viewer_url file -func setCurrentViewerURL(url string) error { - configDir, err := config.Dir() - if err != nil { - return err - } - if err := os.MkdirAll(configDir, 0o700); err != nil { - return err - } - return os.WriteFile(filepath.Join(configDir, config.CurrentViewerURLFile), []byte(url), 0o600) -} - -// getCurrentViewerURL reads the viewer URL from the current_viewer_url file -func getCurrentViewerURL() string { - configDir, err := config.Dir() - if err != nil { - return "" - } - data, err := os.ReadFile(filepath.Join(configDir, config.CurrentViewerURLFile)) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -// clearCurrentViewerURL removes the current_viewer_url file -func clearCurrentViewerURL() error { - configDir, err := config.Dir() - if err != nil { - return err - } - path := filepath.Join(configDir, config.CurrentViewerURLFile) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// setCurrentSessionExpiry saves the session expiry timestamp to the current_session_expiry file -func setCurrentSessionExpiry(t time.Time) error { - configDir, err := config.Dir() - if err != nil { - return err - } - if err := os.MkdirAll(configDir, 0o700); err != nil { - return err - } - return os.WriteFile(filepath.Join(configDir, config.CurrentSessionExpiryFile), []byte(t.Format(time.RFC3339)), 0o600) -} - -// getCurrentSessionExpiry reads the session expiry timestamp from the current_session_expiry file -func getCurrentSessionExpiry() (time.Time, error) { - configDir, err := config.Dir() - if err != nil { - return time.Time{}, err - } - data, err := os.ReadFile(filepath.Join(configDir, config.CurrentSessionExpiryFile)) - if err != nil { - return time.Time{}, err - } - return time.Parse(time.RFC3339, strings.TrimSpace(string(data))) -} - -// clearCurrentSessionExpiry removes the current_session_expiry file -func clearCurrentSessionExpiry() error { - configDir, err := config.Dir() - if err != nil { - return err - } - path := filepath.Join(configDir, config.CurrentSessionExpiryFile) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// RequireSessionID ensures a session ID is available from flag, env, or file +// RequireSessionID ensures a session ID was supplied explicitly. func RequireSessionID() error { - sessionID = GetCurrentSessionID() if sessionID == "" { - return errors.New("session ID required: use --session-id flag, set NOTTE_SESSION_ID env var, or start a session first") + return errors.New("session ID required: use --session-id") } return nil } @@ -193,8 +64,7 @@ var sessionsListCmd = &cobra.Command{ var sessionsStartCmd = &cobra.Command{ Use: "start", - // Flag conflicts must be caught before RunE, which stops the current - // session before it builds the request. + // Flag conflicts must be caught before RunE builds or sends the request. PreRunE: validateSessionStartFlags, Short: "Start a new browser session", RunE: runSessionsStart, @@ -286,8 +156,8 @@ var sessionsReplayCmd = &cobra.Command{ Long: `Download the replay video (MP4) for a session. Examples: - notte sessions replay # saves to temp directory - notte sessions replay --path replay.mp4 # saves to specified path`, + notte sessions replay --session-id # saves to temp directory + notte sessions replay --session-id --path replay.mp4 # saves to specified path`, Args: cobra.NoArgs, RunE: runSessionReplay, } @@ -358,53 +228,67 @@ func init() { sessionsStartCmd.Flags().StringVar(&sessionsStartExtraHttpHeaders, "extra-http-headers", "", `Extra HTTP headers as JSON (e.g. '{"Authorization": "Bearer xxx"}')`) // Status command flags - sessionsStatusCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsStatusCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsStatusCmd.MarkFlagRequired("session-id") // Stop command flags - sessionsStopCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsStopCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsStopCmd.MarkFlagRequired("session-id") // Observe command flags - sessionsObserveCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsObserveCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsObserveCmd.MarkFlagRequired("session-id") // Execute command flags - sessionsExecuteCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsExecuteCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsExecuteCmd.MarkFlagRequired("session-id") sessionsExecuteCmd.Flags().StringVar(&sessionExecuteAction, "action", "", "Action JSON, @file, or '-' for stdin") // Scrape command flags - sessionsScrapeCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsScrapeCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsScrapeCmd.MarkFlagRequired("session-id") sessionsScrapeCmd.Flags().StringVar(&sessionScrapeInstructions, "instructions", "", "Extraction instructions") sessionsScrapeCmd.Flags().BoolVar(&sessionScrapeOnlyMain, "only-main-content", false, "Only scrape main content") // Cookies command flags - sessionsCookiesCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsCookiesCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsCookiesCmd.MarkFlagRequired("session-id") // Cookies-set command flags - sessionsCookiesSetCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsCookiesSetCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsCookiesSetCmd.MarkFlagRequired("session-id") sessionsCookiesSetCmd.Flags().StringVar(&sessionCookiesSetFile, "file", "", "JSON file containing cookies array (required)") _ = sessionsCookiesSetCmd.MarkFlagRequired("file") // Debug command flags - sessionsDebugCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsDebugCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsDebugCmd.MarkFlagRequired("session-id") // Network command flags - sessionsNetworkCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsNetworkCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsNetworkCmd.MarkFlagRequired("session-id") sessionsNetworkCmd.Flags().BoolVar(&sessionNetworkURLsOnly, "urls-only", false, "Only show download URLs without downloading") sessionsNetworkCmd.Flags().StringVar(&sessionNetworkPath, "path", "", "Output directory for downloaded files (defaults to temp directory)") // Replay command flags - sessionsReplayCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsReplayCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsReplayCmd.MarkFlagRequired("session-id") sessionsReplayCmd.Flags().StringVar(&sessionReplayOutput, "path", "", "Output path for the replay video (defaults to temp directory)") // Offset command flags - sessionsOffsetCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsOffsetCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsOffsetCmd.MarkFlagRequired("session-id") // Workflow-code command flags - sessionsWorkflowCodeCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsWorkflowCodeCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsWorkflowCodeCmd.MarkFlagRequired("session-id") // Code command flags - sessionsCodeCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsCodeCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsCodeCmd.MarkFlagRequired("session-id") // Viewer command flags - sessionsViewerCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (uses current session if not specified)") + sessionsViewerCmd.Flags().StringVar(&sessionID, "session-id", "", "Session ID (required)") + _ = sessionsViewerCmd.MarkFlagRequired("session-id") } func runSessionsList(cmd *cobra.Command, args []string) error { @@ -454,42 +338,6 @@ func runSessionsList(cmd *cobra.Command, args []string) error { } func runSessionsStart(cmd *cobra.Command, args []string) error { - // Check if there's already a current session - existingSessionID := GetCurrentSessionID() - if existingSessionID != "" { - // Check if the session has expired based on stored max expiry - if expiry, err := getCurrentSessionExpiry(); err == nil && !expiry.IsZero() && time.Now().UTC().After(expiry) { - // Session has expired — silently clear stale state - _ = clearCurrentSession() - _ = clearCurrentViewerURL() - _ = clearCurrentSessionExpiry() - existingSessionID = "" // skip the confirmation prompt - } - } - if existingSessionID != "" { - confirmed, err := confirmReplaceSession(existingSessionID) - if err != nil { - return err - } - if confirmed { - // Stop the existing session - stopClient, err := GetClient() - if err != nil { - return err - } - ctx, cancel := GetContextWithTimeout(cmd.Context()) - params := &api.SessionStopParams{} - _, stopErr := stopClient.Client().SessionStopWithResponse(ctx, existingSessionID, params) - cancel() - if stopErr != nil { - PrintInfo(fmt.Sprintf("Warning: could not stop session %s: %v", existingSessionID, stopErr)) - } - _ = clearCurrentSession() - _ = clearCurrentViewerURL() - _ = clearCurrentSessionExpiry() - } - } - client, err := GetClient() if err != nil { return err @@ -588,26 +436,6 @@ func runSessionsStart(cmd *cobra.Command, args []string) error { return err } - // Save session ID as current session - if resp.JSON200 != nil { - if err := setCurrentSession(resp.JSON200.SessionId); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save current session: %v", err)) - } - // Store session expiry if max duration is set - if resp.JSON200.MaxDurationMinutes != nil && !resp.JSON200.CreatedAt.IsZero() { - expiry := resp.JSON200.CreatedAt.Add(time.Duration(*resp.JSON200.MaxDurationMinutes) * time.Minute) - if err := setCurrentSessionExpiry(expiry); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save session expiry: %v", err)) - } - } - // Store viewer URL if available - if resp.JSON200.ViewerUrl != nil && *resp.JSON200.ViewerUrl != "" { - if err := setCurrentViewerURL(*resp.JSON200.ViewerUrl); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save viewer URL: %v", err)) - } - } - } - formatter := GetFormatter() return formatter.Print(resp.JSON200) } @@ -668,17 +496,6 @@ func runSessionStop(cmd *cobra.Command, args []string) error { return err } - // Clear current session only if it matches the stopped session - configDir, _ := config.Dir() - if configDir != "" { - data, _ := os.ReadFile(filepath.Join(configDir, config.CurrentSessionFile)) - if strings.TrimSpace(string(data)) == sessionID { - _ = clearCurrentSession() - _ = clearCurrentViewerURL() - _ = clearCurrentSessionExpiry() - } - } - return PrintResult(fmt.Sprintf("Session %s stopped.", sessionID), map[string]any{ "id": sessionID, "status": "stopped", @@ -1275,31 +1092,27 @@ func runSessionViewer(cmd *cobra.Command, args []string) error { return err } - viewerURL := getCurrentViewerURL() - - // Fallback: fetch viewer URL from session status if not stored locally - if viewerURL == "" { - client, err := GetClient() - if err != nil { - return err - } + client, err := GetClient() + if err != nil { + return err + } - ctx, cancel := GetContextWithTimeout(cmd.Context()) - defer cancel() + ctx, cancel := GetContextWithTimeout(cmd.Context()) + defer cancel() - params := &api.SessionStatusParams{} - resp, err := client.Client().SessionStatusWithResponse(ctx, sessionID, params) - if err != nil { - return fmt.Errorf("API request failed: %w", err) - } + params := &api.SessionStatusParams{} + resp, err := client.Client().SessionStatusWithResponse(ctx, sessionID, params) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } - if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil { - return err - } + if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil { + return err + } - if resp.JSON200 != nil && resp.JSON200.ViewerUrl != nil { - viewerURL = *resp.JSON200.ViewerUrl - } + viewerURL := "" + if resp.JSON200 != nil && resp.JSON200.ViewerUrl != nil { + viewerURL = *resp.JSON200.ViewerUrl } if viewerURL == "" { diff --git a/internal/cmd/sessions_test.go b/internal/cmd/sessions_test.go index 6d49b94..0535011 100644 --- a/internal/cmd/sessions_test.go +++ b/internal/cmd/sessions_test.go @@ -5,14 +5,11 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "strings" "testing" - "time" "github.com/spf13/cobra" - "github.com/nottelabs/notte-cli/internal/config" "github.com/nottelabs/notte-cli/internal/testutil" ) @@ -842,31 +839,7 @@ func TestRunSessionWorkflowCode(t *testing.T) { } } -// Tests for session ID resolution (file-based tracking) - -func setupSessionFileTest(t *testing.T) string { - t.Helper() - - // Create a temporary config directory - tmpDir := t.TempDir() - config.SetTestConfigDir(tmpDir) - t.Cleanup(func() { config.SetTestConfigDir("") }) - - return tmpDir -} - -func TestGetCurrentSessionID_FromFlag(t *testing.T) { - origID := sessionID - sessionID = "flag_session" - t.Cleanup(func() { sessionID = origID }) - - got := GetCurrentSessionID() - if got != "flag_session" { - t.Errorf("GetCurrentSessionID() = %q, want %q", got, "flag_session") - } -} - -func TestGetCurrentSessionID_FromEnvVar(t *testing.T) { +func TestRequireSessionID_RequiresExplicitID(t *testing.T) { origID := sessionID sessionID = "" t.Cleanup(func() { sessionID = origID }) @@ -874,617 +847,21 @@ func TestGetCurrentSessionID_FromEnvVar(t *testing.T) { env := testutil.SetupTestEnv(t) env.SetEnv("NOTTE_SESSION_ID", "env_session") - got := GetCurrentSessionID() - if got != "env_session" { - t.Errorf("GetCurrentSessionID() = %q, want %q", got, "env_session") - } -} - -func TestGetCurrentSessionID_FromFile(t *testing.T) { - origID := sessionID - sessionID = "" - t.Cleanup(func() { sessionID = origID }) - - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_SESSION_ID", "") // Ensure env var is empty - - // Create temp config dir - tmpDir := setupSessionFileTest(t) - - // Write session file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - if err := os.WriteFile(sessionFile, []byte("file_session"), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - - got := GetCurrentSessionID() - if got != "file_session" { - t.Errorf("GetCurrentSessionID() = %q, want %q", got, "file_session") - } -} - -func TestGetCurrentSessionID_Priority(t *testing.T) { - origID := sessionID - t.Cleanup(func() { sessionID = origID }) - - env := testutil.SetupTestEnv(t) - tmpDir := setupSessionFileTest(t) - - // Create session file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - if err := os.WriteFile(sessionFile, []byte("file_session"), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - - // Test: flag > env > file - sessionID = "flag_session" - env.SetEnv("NOTTE_SESSION_ID", "env_session") - - got := GetCurrentSessionID() - if got != "flag_session" { - t.Errorf("flag should have highest priority: got %q, want %q", got, "flag_session") - } - - // Test: env > file - sessionID = "" - got = GetCurrentSessionID() - if got != "env_session" { - t.Errorf("env should have priority over file: got %q, want %q", got, "env_session") - } - - // Test: file as fallback - env.SetEnv("NOTTE_SESSION_ID", "") - got = GetCurrentSessionID() - if got != "file_session" { - t.Errorf("file should be fallback: got %q, want %q", got, "file_session") - } -} - -func TestSetCurrentSession(t *testing.T) { - tmpDir := setupSessionFileTest(t) - - err := setCurrentSession("test_session_id") - if err != nil { - t.Fatalf("setCurrentSession() error = %v", err) - } - - // Verify file was created - configDir := filepath.Join(tmpDir, config.ConfigDirName) - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - - data, err := os.ReadFile(sessionFile) - if err != nil { - t.Fatalf("failed to read session file: %v", err) - } - - if string(data) != "test_session_id" { - t.Errorf("session file content = %q, want %q", string(data), "test_session_id") - } -} - -func TestClearCurrentSession(t *testing.T) { - tmpDir := setupSessionFileTest(t) - - // First create a session file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - if err := os.WriteFile(sessionFile, []byte("test_session"), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - - // Clear it - err := clearCurrentSession() - if err != nil { - t.Fatalf("clearCurrentSession() error = %v", err) - } - - // Verify file was removed - if _, err := os.Stat(sessionFile); !os.IsNotExist(err) { - t.Error("session file should have been removed") - } -} - -func TestClearCurrentSession_NoFile(t *testing.T) { - _ = setupSessionFileTest(t) - - // Should not error when file doesn't exist - err := clearCurrentSession() - if err != nil { - t.Errorf("clearCurrentSession() should not error when file doesn't exist: %v", err) - } -} - -func TestRequireSessionID_NoSession(t *testing.T) { - origID := sessionID - sessionID = "" - t.Cleanup(func() { sessionID = origID }) - - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_SESSION_ID", "") - _ = setupSessionFileTest(t) - err := RequireSessionID() if err == nil { - t.Fatal("RequireSessionID() should error when no session ID available") + t.Fatal("RequireSessionID() should reject implicit session IDs") } - - expectedMsg := "session ID required" - if !strings.Contains(err.Error(), expectedMsg) { - t.Errorf("error message should contain %q, got %q", expectedMsg, err.Error()) + if !strings.Contains(err.Error(), "use --session-id") { + t.Fatalf("unexpected error: %v", err) } } -func TestRequireSessionID_FromFile(t *testing.T) { +func TestRequireSessionID_WithExplicitID(t *testing.T) { origID := sessionID - sessionID = "" + sessionID = "sess_explicit" t.Cleanup(func() { sessionID = origID }) - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_SESSION_ID", "") - tmpDir := setupSessionFileTest(t) - - // Create session file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - if err := os.WriteFile(sessionFile, []byte("file_session"), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - - err := RequireSessionID() - if err != nil { + if err := RequireSessionID(); err != nil { t.Fatalf("RequireSessionID() error = %v", err) } - - if sessionID != "file_session" { - t.Errorf("sessionID = %q, want %q", sessionID, "file_session") - } -} - -func TestSessionsStart_SetsCurrentSession(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupSessionFileTest(t) - - server.AddResponse("/sessions/start", 200, `{"session_id":"sess_new_123","status":"ACTIVE","created_at":"2020-01-01T00:00:00Z","last_accessed_at":"2020-01-01T00:00:00Z","timeout_minutes":5}`) - - origFormat := outputFormat - outputFormat = "json" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.Flags().BoolVar(&SessionStartHeadless, "headless", true, "") - cmd.Flags().BoolVar(&sessionsStartProxy, "proxy", false, "") - cmd.Flags().BoolVar(&SessionStartSolveCaptchas, "solve-captchas", false, "") - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runSessionsStart(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify session was saved to file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - - data, err := os.ReadFile(sessionFile) - if err != nil { - t.Fatalf("failed to read session file: %v", err) - } - - if string(data) != "sess_new_123" { - t.Errorf("session file content = %q, want %q", string(data), "sess_new_123") - } -} - -func TestSessionStop_ClearsCurrentSession(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupSessionFileTest(t) - - // Create session file first - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - if err := os.WriteFile(sessionFile, []byte(sessionIDTest), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - - server.AddResponse("/sessions/"+sessionIDTest+"/stop", 200, sessionJSON()) - - origID := sessionID - sessionID = sessionIDTest - t.Cleanup(func() { sessionID = origID }) - - SetSkipConfirmation(true) - t.Cleanup(func() { SetSkipConfirmation(false) }) - - origFormat := outputFormat - outputFormat = "text" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runSessionStop(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify session file was cleared - if _, err := os.Stat(sessionFile); !os.IsNotExist(err) { - t.Error("session file should have been removed after stop") - } -} - -func TestSessionStop_DifferentSession_DoesNotClearCurrentSession(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupSessionFileTest(t) - - // Create session file with "sess_current" - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - if err := os.WriteFile(sessionFile, []byte("sess_current"), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - - // Stop a different session "sess_different" - server.AddResponse("/sessions/sess_different/stop", 200, `{"session_id":"sess_different","status":"STOPPED"}`) - - origID := sessionID - sessionID = "sess_different" - t.Cleanup(func() { sessionID = origID }) - - SetSkipConfirmation(true) - t.Cleanup(func() { SetSkipConfirmation(false) }) - - origFormat := outputFormat - outputFormat = "text" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runSessionStop(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify session file still contains "sess_current" - data, err := os.ReadFile(sessionFile) - if err != nil { - t.Fatalf("session file should still exist: %v", err) - } - if strings.TrimSpace(string(data)) != "sess_current" { - t.Errorf("session file content = %q, want %q", string(data), "sess_current") - } -} - -// Tests for session expiry helpers and auto-clear logic - -func TestSetAndGetCurrentSessionExpiry(t *testing.T) { - _ = setupSessionFileTest(t) - - expiry := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC) - if err := setCurrentSessionExpiry(expiry); err != nil { - t.Fatalf("setCurrentSessionExpiry() error = %v", err) - } - - got, err := getCurrentSessionExpiry() - if err != nil { - t.Fatalf("getCurrentSessionExpiry() error = %v", err) - } - - if !got.Equal(expiry) { - t.Errorf("getCurrentSessionExpiry() = %v, want %v", got, expiry) - } -} - -func TestGetCurrentSessionExpiry_MissingFile(t *testing.T) { - _ = setupSessionFileTest(t) - - _, err := getCurrentSessionExpiry() - if err == nil { - t.Fatal("getCurrentSessionExpiry() should error when file doesn't exist") - } -} - -func TestClearCurrentSessionExpiry(t *testing.T) { - tmpDir := setupSessionFileTest(t) - - // Create expiry file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - expiryFile := filepath.Join(configDir, config.CurrentSessionExpiryFile) - if err := os.WriteFile(expiryFile, []byte("2025-06-15T12:30:00Z"), 0o600); err != nil { - t.Fatalf("failed to write expiry file: %v", err) - } - - if err := clearCurrentSessionExpiry(); err != nil { - t.Fatalf("clearCurrentSessionExpiry() error = %v", err) - } - - if _, err := os.Stat(expiryFile); !os.IsNotExist(err) { - t.Error("expiry file should have been removed") - } -} - -func TestClearCurrentSessionExpiry_NoFile(t *testing.T) { - _ = setupSessionFileTest(t) - - err := clearCurrentSessionExpiry() - if err != nil { - t.Errorf("clearCurrentSessionExpiry() should not error when file doesn't exist: %v", err) - } -} - -func TestSessionsStart_SavesExpiry(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupSessionFileTest(t) - - // Response with max_duration_minutes set - server.AddResponse("/sessions/start", 200, `{"session_id":"sess_exp","status":"ACTIVE","created_at":"2025-06-15T12:00:00Z","last_accessed_at":"2025-06-15T12:00:00Z","timeout_minutes":5,"max_duration_minutes":30}`) - - origFormat := outputFormat - outputFormat = "json" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.Flags().BoolVar(&SessionStartHeadless, "headless", true, "") - cmd.Flags().BoolVar(&sessionsStartProxy, "proxy", false, "") - cmd.Flags().BoolVar(&SessionStartSolveCaptchas, "solve-captchas", false, "") - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runSessionsStart(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify expiry was saved - configDir := filepath.Join(tmpDir, config.ConfigDirName) - expiryFile := filepath.Join(configDir, config.CurrentSessionExpiryFile) - - data, err := os.ReadFile(expiryFile) - if err != nil { - t.Fatalf("expiry file should exist: %v", err) - } - - got, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data))) - if err != nil { - t.Fatalf("failed to parse expiry: %v", err) - } - - // created_at (12:00) + 30 minutes = 12:30 - expected := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC) - if !got.Equal(expected) { - t.Errorf("expiry = %v, want %v", got, expected) - } -} - -func TestSessionsStart_AutoClearsExpiredSession(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupSessionFileTest(t) - - // Set up an existing session with an expiry in the past - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - if err := os.WriteFile(filepath.Join(configDir, config.CurrentSessionFile), []byte("sess_old"), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - // Set expiry to the past - pastExpiry := time.Now().UTC().Add(-10 * time.Minute) - if err := os.WriteFile(filepath.Join(configDir, config.CurrentSessionExpiryFile), []byte(pastExpiry.Format(time.RFC3339)), 0o600); err != nil { - t.Fatalf("failed to write expiry file: %v", err) - } - - // The start endpoint should be called (no stop for the expired session) - server.AddResponse("/sessions/start", 200, `{"session_id":"sess_new","status":"ACTIVE","created_at":"2025-06-15T12:00:00Z","last_accessed_at":"2025-06-15T12:00:00Z","timeout_minutes":5}`) - - origFormat := outputFormat - outputFormat = "json" - t.Cleanup(func() { outputFormat = origFormat }) - - // Reset sessionID flag so file-based resolution is used - origID := sessionID - sessionID = "" - t.Cleanup(func() { sessionID = origID }) - env.SetEnv("NOTTE_SESSION_ID", "") - - cmd := &cobra.Command{} - cmd.Flags().BoolVar(&SessionStartHeadless, "headless", true, "") - cmd.Flags().BoolVar(&sessionsStartProxy, "proxy", false, "") - cmd.Flags().BoolVar(&SessionStartSolveCaptchas, "solve-captchas", false, "") - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runSessionsStart(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify old session was cleared and new one was saved - data, err := os.ReadFile(filepath.Join(configDir, config.CurrentSessionFile)) - if err != nil { - t.Fatalf("session file should exist: %v", err) - } - if string(data) != "sess_new" { - t.Errorf("session file = %q, want %q", string(data), "sess_new") - } - - // Old expiry file should have been cleared (no new one since no max_duration_minutes in response) - if _, err := os.Stat(filepath.Join(configDir, config.CurrentSessionExpiryFile)); !os.IsNotExist(err) { - t.Error("expiry file should have been cleared for expired session") - } -} - -func TestSessionsStart_DoesNotAutoClearNonExpiredSession(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupSessionFileTest(t) - - // Set up an existing session with an expiry in the future - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - if err := os.WriteFile(filepath.Join(configDir, config.CurrentSessionFile), []byte("sess_active"), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - futureExpiry := time.Now().UTC().Add(30 * time.Minute) - if err := os.WriteFile(filepath.Join(configDir, config.CurrentSessionExpiryFile), []byte(futureExpiry.Format(time.RFC3339)), 0o600); err != nil { - t.Fatalf("failed to write expiry file: %v", err) - } - - // Reset sessionID flag so file-based resolution is used - origID := sessionID - sessionID = "" - t.Cleanup(func() { sessionID = origID }) - env.SetEnv("NOTTE_SESSION_ID", "") - - // Set up stop endpoint (for the confirmation path) and start endpoint - server.AddResponse("/sessions/sess_active/stop", 200, `{"session_id":"sess_active","status":"STOPPED"}`) - server.AddResponse("/sessions/start", 200, `{"session_id":"sess_new","status":"ACTIVE","created_at":"2025-06-15T12:00:00Z","last_accessed_at":"2025-06-15T12:00:00Z","timeout_minutes":5}`) - - // Skip confirmation to auto-confirm the replace prompt - SetSkipConfirmation(true) - t.Cleanup(func() { SetSkipConfirmation(false) }) - - origFormat := outputFormat - outputFormat = "json" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.Flags().BoolVar(&SessionStartHeadless, "headless", true, "") - cmd.Flags().BoolVar(&sessionsStartProxy, "proxy", false, "") - cmd.Flags().BoolVar(&SessionStartSolveCaptchas, "solve-captchas", false, "") - cmd.SetContext(context.Background()) - - testutil.CaptureOutput(func() { - err := runSessionsStart(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - // Verify the new session was saved (confirmation path was followed, not auto-clear) - data, err := os.ReadFile(filepath.Join(configDir, config.CurrentSessionFile)) - if err != nil { - t.Fatalf("session file should exist: %v", err) - } - if string(data) != "sess_new" { - t.Errorf("session file = %q, want %q", string(data), "sess_new") - } -} - -func TestSessionStatus_UsesCurrentSession(t *testing.T) { - env := testutil.SetupTestEnv(t) - env.SetEnv("NOTTE_API_KEY", "test-key") - - server := testutil.NewMockServer() - defer server.Close() - env.SetEnv("NOTTE_API_URL", server.URL()) - - tmpDir := setupSessionFileTest(t) - - // Create session file - configDir := filepath.Join(tmpDir, config.ConfigDirName) - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - sessionFile := filepath.Join(configDir, config.CurrentSessionFile) - if err := os.WriteFile(sessionFile, []byte(sessionIDTest), 0o600); err != nil { - t.Fatalf("failed to write session file: %v", err) - } - - server.AddResponse("/sessions/"+sessionIDTest, 200, sessionJSON()) - - // Clear sessionID to test file-based resolution - origID := sessionID - sessionID = "" - t.Cleanup(func() { sessionID = origID }) - - // Clear env var too - env.SetEnv("NOTTE_SESSION_ID", "") - - origFormat := outputFormat - outputFormat = "json" - t.Cleanup(func() { outputFormat = origFormat }) - - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - - stdout, _ := testutil.CaptureOutput(func() { - err := runSessionStatus(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - if stdout == "" { - t.Error("expected output, got empty string") - } } diff --git a/internal/cmd/sessionstart_optout.go b/internal/cmd/sessionstart_optout.go index ca6ac6d..f066533 100644 --- a/internal/cmd/sessionstart_optout.go +++ b/internal/cmd/sessionstart_optout.go @@ -81,10 +81,8 @@ func registerSessionStartOptOutFlags(cmd *cobra.Command) { } // validateSessionStartOptOuts rejects a pair whose two spellings were both -// supplied. It is deliberately separate from applying the values: `sessions -// start` stops and clears any current session before it builds the request, so -// validation that runs at build time would take the old session away and then -// refuse to start a new one. This runs from PreRunE, ahead of any side effect. +// supplied. It is deliberately separate from applying the values so Cobra can +// reject conflicts in PreRunE, before the request is built or sent. func validateSessionStartOptOuts(cmd *cobra.Command) error { for _, o := range sessionStartOptOuts() { if cmd.Flags().Changed(o.negative) && cmd.Flags().Changed(o.original) { @@ -115,8 +113,7 @@ func applySessionStartOptOuts(cmd *cobra.Command, body *api.ApiSessionStartReque } // validateSessionStartProxyFlags reports more than one proxy kind being -// selected. Same reasoning as above: this used to run after the current session -// had already been stopped. +// selected. It runs before the request is built or sent. func validateSessionStartProxyFlags(cmd *cobra.Command) error { var set []string for _, name := range []string{"proxy", "proxy-country", "proxy-external-server", "proxy-tailnet-client-id"} { diff --git a/internal/cmd/sessionstart_optout_test.go b/internal/cmd/sessionstart_optout_test.go index efefcbc..cd7f7f8 100644 --- a/internal/cmd/sessionstart_optout_test.go +++ b/internal/cmd/sessionstart_optout_test.go @@ -147,14 +147,11 @@ func TestOriginalFlagsStillWorkAlone(t *testing.T) { } // TestConflictsRejectedBeforeSideEffects pins the ordering, not just the error. -// runSessionsStart stops and clears the current session before it builds the -// request, so a conflict detected at build time would cost the user their -// existing session and give them nothing back. Validation therefore has to hang -// off PreRunE, which cobra runs before RunE. +// Validation has to hang off PreRunE, which Cobra runs before RunE and before +// any API request can be sent. func TestConflictsRejectedBeforeSideEffects(t *testing.T) { if sessionsStartCmd.PreRunE == nil { - t.Fatal("sessions start must validate flags in PreRunE; validating inside RunE " + - "runs after the current session has already been stopped") + t.Fatal("sessions start must validate flags in PreRunE before RunE sends an API request") } ranRunE := false diff --git a/internal/config/config.go b/internal/config/config.go index 2c8d676..42a9bdb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,17 +12,11 @@ const ( DefaultConsoleURL = "https://console.notte.cc" ConfigDirName = ".notte/cli" ConfigFileName = "config.json" - CurrentSessionFile = "current_session" - CurrentFunctionFile = "current_function" - CurrentViewerURLFile = "current_viewer_url" - CurrentSessionExpiryFile = "current_session_expiry" DefaultRequestOrigin = "cli" EnvConfigDir = "NOTTE_CONFIG_DIR" EnvAPIURL = "NOTTE_API_URL" EnvConsoleURL = "NOTTE_CONSOLE_URL" EnvRequestOrigin = "NOTTE_REQUEST_ORIGIN" - EnvSessionID = "NOTTE_SESSION_ID" - EnvFunctionID = "NOTTE_FUNCTION_ID" EnvNoUpdateCheck = "NOTTE_NO_UPDATE_CHECK" ) diff --git a/tests/integration/functions_test.go b/tests/integration/functions_test.go index d97aee3..e654516 100644 --- a/tests/integration/functions_test.go +++ b/tests/integration/functions_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "os" "os/exec" - "path/filepath" "strings" "testing" "time" @@ -217,140 +216,6 @@ func TestFunctionsLifecycle(t *testing.T) { t.Log("Function lifecycle test completed successfully") } -func TestFunctionIDResolution(t *testing.T) { - // This test verifies the function ID resolution feature: - // 1. Create a function and verify current_function file is created - // 2. Run subsequent commands without --function-id and verify they use the saved function - // 3. Delete the function and verify current_function file is cleared - - tmpFile := createTempFunctionFile(t) - - // Step 1: Create a new function - result := runCLI(t, "functions", "create", "--file", tmpFile, "--name", "id-resolution-test") - requireSuccess(t, result) - - var createResp struct { - FunctionID string `json:"function_id"` - } - if err := json.Unmarshal([]byte(result.Stdout), &createResp); err != nil { - t.Fatalf("Failed to parse function create response: %v", err) - } - functionID := createResp.FunctionID - if functionID == "" { - t.Fatal("No function ID returned from create command") - } - t.Logf("Created function: %s", functionID) - - // Track whether the function was already deleted (by the test logic) - deleted := false - defer func() { - if !deleted { - cleanupFunction(t, functionID) - } - }() - - // Step 2: Verify current_function file was created - homeDir, err := os.UserHomeDir() - if err != nil { - t.Fatalf("Failed to get home dir: %v", err) - } - currentFunctionFile := filepath.Join(homeDir, ".notte", "cli", "current_function") - data, err := os.ReadFile(currentFunctionFile) - if err != nil { - t.Fatalf("Failed to read current_function file: %v", err) - } - if string(data) != functionID { - t.Errorf("current_function file contains %q, expected %q", string(data), functionID) - } - t.Log("Verified current_function file was created with correct function ID") - - // Step 3: Test show command without --function-id should use the saved function - result = runCLI(t, "functions", "show") - requireSuccess(t, result) - if !containsString(result.Stdout, functionID) { - t.Error("Function show without --function-id did not return the current function") - } - t.Log("Successfully used current function for 'show' command") - - // Step 4: Test runs command without --function-id should use the saved function - result = runCLI(t, "functions", "runs") - requireSuccess(t, result) - t.Log("Successfully used current function for 'runs' command") - - // Step 4a: Test run command without --function-id should use the saved function - result = runCLI(t, "functions", "run") - requireSuccess(t, result) - if !containsString(result.Stdout, functionID) { - t.Error("Function run without --function-id did not use the current function") - } - t.Log("Successfully used current function for 'run' command") - - // Step 5: Test delete command without --function-id should use the saved function and clear it - result = runCLI(t, "functions", "delete") - requireSuccess(t, result) - deleted = true // Mark as deleted so deferred cleanup is skipped - t.Log("Successfully deleted current function") - - // Step 6: Verify current_function file was cleared - if _, err := os.Stat(currentFunctionFile); !os.IsNotExist(err) { - data, readErr := os.ReadFile(currentFunctionFile) - if readErr == nil && string(data) == functionID { - t.Error("current_function file should have been cleared after delete") - } - } - t.Log("Verified current_function file was cleared after delete") -} - -func TestFunctionIDResolutionPriority(t *testing.T) { - // Test priority: --function-id flag > NOTTE_FUNCTION_ID env var > current_function file - tmpFile := createTempFunctionFile(t) - - // Create first function (this sets current_function) - result := runCLI(t, "functions", "create", "--file", tmpFile, "--name", "priority-test-function1") - requireSuccess(t, result) - - var createResp1 struct { - FunctionID string `json:"function_id"` - } - if err := json.Unmarshal([]byte(result.Stdout), &createResp1); err != nil { - t.Fatalf("Failed to parse function create response: %v", err) - } - functionID1 := createResp1.FunctionID - defer cleanupFunction(t, functionID1) - t.Logf("Created first function: %s", functionID1) - - // Create second function (this overwrites current_function) - result = runCLI(t, "functions", "create", "--file", tmpFile, "--name", "priority-test-function2") - requireSuccess(t, result) - - var createResp2 struct { - FunctionID string `json:"function_id"` - } - if err := json.Unmarshal([]byte(result.Stdout), &createResp2); err != nil { - t.Fatalf("Failed to parse function create response: %v", err) - } - functionID2 := createResp2.FunctionID - defer cleanupFunction(t, functionID2) - t.Logf("Created second function: %s", functionID2) - - // Test 1: env var should take priority over file - // Current function file has functionID2, env var has functionID1 - result = runCLIWithEnv(t, map[string]string{"NOTTE_FUNCTION_ID": functionID1}, "functions", "show") - requireSuccess(t, result) - if !containsString(result.Stdout, functionID1) { - t.Errorf("Expected function1 (%s) when using env var, but got different function", functionID1) - } - t.Log("Verified env var takes priority over current_function file") - - // Test 2: --function-id flag should take priority over env var - result = runCLIWithEnv(t, map[string]string{"NOTTE_FUNCTION_ID": functionID1}, "functions", "show", "--function-id", functionID2) - requireSuccess(t, result) - if !containsString(result.Stdout, functionID2) { - t.Errorf("Expected function2 (%s) when using --function-id flag, but got different function", functionID2) - } - t.Log("Verified --function-id flag takes priority over env var") -} - func TestFunctionsUpdate(t *testing.T) { tmpFile := createTempFunctionFile(t) @@ -518,22 +383,15 @@ func TestFunctionsScheduleAndUnschedule(t *testing.T) { } func TestFunctionsNoIDProvided(t *testing.T) { - // Clear any existing current_function file - homeDir, err := os.UserHomeDir() - if err == nil { - currentFunctionFile := filepath.Join(homeDir, ".notte", "cli", "current_function") - os.Remove(currentFunctionFile) - } - - // Clear NOTTE_FUNCTION_ID env var and try to show without --function-id - result := runCLIWithEnv(t, map[string]string{"NOTTE_FUNCTION_ID": ""}, "functions", "show") + // An environment variable must not substitute for the required flag. + result := runCLIWithEnv(t, map[string]string{"NOTTE_FUNCTION_ID": "fn_ignored"}, "functions", "show") requireFailure(t, result) // Should contain helpful error message - if !containsString(result.Stderr, "function ID required") && !containsString(result.Stdout, "function ID required") { - t.Log("Expected error message about function ID being required") + if !containsString(result.Stderr, "function-id") && !containsString(result.Stdout, "function-id") { + t.Fatalf("expected missing --function-id error, stdout=%q stderr=%q", result.Stdout, result.Stderr) } - t.Log("Correctly failed when no function ID is available") + t.Log("Correctly rejected an implicit function ID") } func TestFunctionRun(t *testing.T) { diff --git a/tests/integration/page_commands_test.go b/tests/integration/page_commands_test.go index 12d7b99..8e9907b 100644 --- a/tests/integration/page_commands_test.go +++ b/tests/integration/page_commands_test.go @@ -315,28 +315,13 @@ func TestPageFormFill(t *testing.T) { t.Log("Successfully executed page form-fill") } -// TestPageUsesCurrentSession tests that page commands use the current session when --session-id is not specified -func TestPageUsesCurrentSession(t *testing.T) { - // Start a session (this sets the current session) - result := runCLI(t, "sessions", "start", "--headless") - requireSuccess(t, result) - - var startResp struct { - SessionID string `json:"session_id"` - } - if err := json.Unmarshal([]byte(result.Stdout), &startResp); err != nil { - t.Fatalf("Failed to parse session start response: %v", err) +// TestPageRequiresSessionID verifies that page commands reject implicit IDs. +func TestPageRequiresSessionID(t *testing.T) { + result := runCLIWithTimeout(t, 120*time.Second, "page", "goto", "https://example.com") + requireFailure(t, result) + if !containsString(result.Stderr, "session-id") && !containsString(result.Stdout, "session-id") { + t.Fatalf("expected missing --session-id error, stdout=%q stderr=%q", result.Stdout, result.Stderr) } - sessionID := startResp.SessionID - defer cleanupSession(t, sessionID) - - // Wait for session to be ready - time.Sleep(2 * time.Second) - - // Run page command WITHOUT --session-id flag (should use current session) - result = runCLIWithTimeout(t, 120*time.Second, "page", "goto", "https://example.com") - requireSuccess(t, result) - t.Log("Successfully used current session without --session-id flag") } // TestPageCommandErrors tests error handling for page commands