Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions internal/cmd/output_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"os"
"reflect"

"github.com/salmonumbrella/notte-cli/internal/api"
)

// IsJSONOutput returns true if the global output format is set to JSON.
Expand Down Expand Up @@ -74,3 +76,34 @@ func PrintListOrEmpty(items any, emptyMsg string) (bool, error) {

return false, nil
}

// PrintScrapeResponse formats scrape output consistently across all scrape commands.
// In JSON mode, returns the full response. In text mode without instructions,
// returns just the markdown. With instructions, checks data.success and returns
// the extracted data or an error message.
func PrintScrapeResponse(resp *api.ScrapeResponse, hasInstructions bool) error {
// JSON mode: return full response
if IsJSONOutput() {
return GetFormatter().Print(resp)
}

if !hasInstructions {
// Simple mode: just return markdown
fmt.Println(resp.Markdown)
return nil
}

// Structured mode: check data.success
if data, ok := resp.Structured.(map[string]any); ok {
if success, ok := data["success"].(bool); ok && !success {
if errMsg, ok := data["error"].(string); ok {
return fmt.Errorf("%s", errMsg)
}
return fmt.Errorf("scrape failed")
}
if resultData, ok := data["data"]; ok {
return GetFormatter().Print(resultData)
}
}
return GetFormatter().Print(resp.Structured)
}
118 changes: 110 additions & 8 deletions internal/cmd/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,37 @@ var (

// form-fill flags
pageFormFillData string

// observe flags
pageObserveURL string
)

// printExecuteResponse formats execute response output.
// In JSON mode, returns the full response. In text mode, prints
// only the message and data fields, hiding the Session field.
func printExecuteResponse(resp *api.ApiExecutionResponse) error {
// JSON mode: return full response
if IsJSONOutput() {
return GetFormatter().Print(resp)
}

if !resp.Success {
if resp.Exception != nil {
return fmt.Errorf("%s", *resp.Exception)
}
return fmt.Errorf("action failed")
}

// Print message
fmt.Println(resp.Message)

// Print data if non-nil
if resp.Data != nil {
return GetFormatter().Print(resp.Data)
}
return nil
}

// parseSelector returns (id, selector, error) based on @ prefix
// @B3 -> element ID (id: "B3")
// #btn or any other string -> CSS selector (selector: "#btn")
Expand Down Expand Up @@ -84,7 +113,7 @@ func executePageAction(cmd *cobra.Command, action map[string]any) error {
return err
}

return GetFormatter().Print(resp.JSON200)
return printExecuteResponse(resp.JSON200)
}

var pageCmd = &cobra.Command{
Expand Down Expand Up @@ -449,26 +478,95 @@ func runPageWait(cmd *cobra.Command, args []string) error {
return executePageAction(cmd, action)
}

// Page State

var pageObserveCmd = &cobra.Command{
Use: "observe",
Short: "Observe the current page state",
Args: cobra.NoArgs,
RunE: runPageObserve,
}

func runPageObserve(cmd *cobra.Command, args []string) error {
if err := RequireSessionID(); err != nil {
return err
}

client, err := GetClient()
if err != nil {
return err
}

ctx, cancel := GetContextWithTimeout(cmd.Context())
defer cancel()

body := api.PageObserveJSONRequestBody{}
if pageObserveURL != "" {
body.Url = &pageObserveURL
}

params := &api.PageObserveParams{}
resp, err := client.Client().PageObserveWithResponse(ctx, sessionID, params, body)
if err != nil {
return fmt.Errorf("API request failed: %w", err)
}

if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil {
return err
}

// JSON mode: return full response
if IsJSONOutput() {
return GetFormatter().Print(resp.JSON200)
}

// Text mode: return only the page description
fmt.Println(resp.JSON200.Space.Description)
return nil
}

// Data Extraction

var pageScrapeCmd = &cobra.Command{
Use: "scrape <instructions>",
Use: "scrape [instructions]",
Short: "Scrape content from the page",
Args: cobra.ExactArgs(1),
Args: cobra.MaximumNArgs(1),
RunE: runPageScrape,
}

func runPageScrape(cmd *cobra.Command, args []string) error {
action := map[string]any{
"type": "scrape",
"instructions": args[0],
if err := RequireSessionID(); err != nil {
return err
}

client, err := GetClient()
if err != nil {
return err
}

ctx, cancel := GetContextWithTimeout(cmd.Context())
defer cancel()

body := api.PageScrapeJSONRequestBody{}
hasInstructions := len(args) > 0
if hasInstructions {
body.Instructions = &args[0]
}
if pageScrapeMainOnly {
action["only_main_content"] = true
body.OnlyMainContent = &pageScrapeMainOnly
}

return executePageAction(cmd, action)
params := &api.PageScrapeParams{}
resp, err := client.Client().PageScrapeWithResponse(ctx, sessionID, params, body)
if err != nil {
return fmt.Errorf("API request failed: %w", err)
}

if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil {
return err
}

return PrintScrapeResponse(resp.JSON200, hasInstructions)
}

// Other Actions
Expand Down Expand Up @@ -545,6 +643,7 @@ func init() {
pageCmd.AddCommand(pageSwitchTabCmd)
pageCmd.AddCommand(pageCloseTabCmd)
pageCmd.AddCommand(pageWaitCmd)
pageCmd.AddCommand(pageObserveCmd)
pageCmd.AddCommand(pageScrapeCmd)
pageCmd.AddCommand(pageCaptchaSolveCmd)
pageCmd.AddCommand(pageCompleteCmd)
Expand All @@ -568,6 +667,9 @@ func init() {
pageUploadCmd.Flags().StringVar(&pageUploadFile, "file", "", "Path to the file to upload (required)")
_ = pageUploadCmd.MarkFlagRequired("file")

// observe flags
pageObserveCmd.Flags().StringVar(&pageObserveURL, "url", "", "Navigate to URL before observing")

// scrape flags
pageScrapeCmd.Flags().BoolVar(&pageScrapeMainOnly, "main-only", false, "Only scrape main content")

Expand Down
80 changes: 78 additions & 2 deletions internal/cmd/page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/spf13/cobra"

"github.com/salmonumbrella/notte-cli/internal/config"
"github.com/salmonumbrella/notte-cli/internal/testutil"
)

Expand Down Expand Up @@ -36,6 +37,10 @@ func pageExecResponse() string {
return `{"action":{"type":"click"},"data":{},"message":"ok","session":{"session_id":"` + pageSessionIDTest + `","status":"ACTIVE"},"success":true}`
}

func pageScrapeResponse() string {
return `{"markdown":"# Test","session":{"session_id":"` + pageSessionIDTest + `","status":"ACTIVE"},"structured":{"success":true,"data":{"title":"Test"}}}`
}

// Test parseSelector helper
func TestParseSelector(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -555,7 +560,7 @@ func TestRunPageWait_InvalidTime(t *testing.T) {

func TestRunPageScrape(t *testing.T) {
server := setupPageTest(t)
server.AddResponse("/sessions/"+pageSessionIDTest+"/page/execute", 200, pageExecResponse())
server.AddResponse("/sessions/"+pageSessionIDTest+"/page/scrape", 200, pageScrapeResponse())

cmd := &cobra.Command{}
cmd.SetContext(context.Background())
Expand All @@ -574,7 +579,7 @@ func TestRunPageScrape(t *testing.T) {

func TestRunPageScrape_MainOnly(t *testing.T) {
server := setupPageTest(t)
server.AddResponse("/sessions/"+pageSessionIDTest+"/page/execute", 200, pageExecResponse())
server.AddResponse("/sessions/"+pageSessionIDTest+"/page/scrape", 200, pageScrapeResponse())

origMainOnly := pageScrapeMainOnly
pageScrapeMainOnly = true
Expand All @@ -595,6 +600,73 @@ func TestRunPageScrape_MainOnly(t *testing.T) {
}
}

func TestRunPageScrape_NoInstructions(t *testing.T) {
server := setupPageTest(t)
server.AddResponse("/sessions/"+pageSessionIDTest+"/page/scrape", 200, pageScrapeResponse())

cmd := &cobra.Command{}
cmd.SetContext(context.Background())

stdout, _ := testutil.CaptureOutput(func() {
err := runPageScrape(cmd, []string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

if stdout == "" {
t.Error("expected output, got empty string")
}
}

// Page Observe Tests

func pageObserveResponse() string {
return `{"metadata":{"url":"https://example.com"},"screenshot":{},"session":{"session_id":"` + pageSessionIDTest + `","status":"ACTIVE"},"space":{"description":"A test page with example content","interaction_actions":[]}}`
}

func TestRunPageObserve(t *testing.T) {
server := setupPageTest(t)
server.AddResponse("/sessions/"+pageSessionIDTest+"/page/observe", 200, pageObserveResponse())

cmd := &cobra.Command{}
cmd.SetContext(context.Background())

stdout, _ := testutil.CaptureOutput(func() {
err := runPageObserve(cmd, []string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

if stdout == "" {
t.Error("expected output, got empty string")
}
}

func TestRunPageObserve_WithURL(t *testing.T) {
server := setupPageTest(t)
server.AddResponse("/sessions/"+pageSessionIDTest+"/page/observe", 200, pageObserveResponse())

origURL := pageObserveURL
pageObserveURL = "https://example.com"
t.Cleanup(func() { pageObserveURL = origURL })

cmd := &cobra.Command{}
cmd.SetContext(context.Background())

stdout, _ := testutil.CaptureOutput(func() {
err := runPageObserve(cmd, []string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

if stdout == "" {
t.Error("expected output, got empty string")
}
}

// Other Actions Tests

func TestRunPageCaptchaSolve(t *testing.T) {
Expand Down Expand Up @@ -692,6 +764,10 @@ func TestPageCommand_NoSessionID(t *testing.T) {
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 = ""
Expand Down
12 changes: 9 additions & 3 deletions internal/cmd/scrape.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ func runScrape(cmd *cobra.Command, args []string) error {
Url: url,
}

if scrapeInstructions != "" {
hasInstructions := scrapeInstructions != ""
if hasInstructions {
body.Instructions = &scrapeInstructions
}
if scrapeOnlyMain {
Expand All @@ -75,7 +76,7 @@ func runScrape(cmd *cobra.Command, args []string) error {
return err
}

return GetFormatter().Print(resp.JSON200)
return PrintScrapeResponse(resp.JSON200, hasInstructions)
}

func runScrapeHtml(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -119,5 +120,10 @@ func runScrapeHtml(cmd *cobra.Command, args []string) error {
return err
}

return GetFormatter().Print(resp.JSON200)
// ScrapeFromHtml returns ScrapeSchemaResponse which has a different structure
// Just print the Scrape field which contains the extracted data
if IsJSONOutput() {
return GetFormatter().Print(resp.JSON200)
}
return GetFormatter().Print(resp.JSON200.Scrape)
}
5 changes: 3 additions & 2 deletions internal/cmd/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,8 @@ func runSessionScrape(cmd *cobra.Command, args []string) error {
defer cancel()

body := api.PageScrapeJSONRequestBody{}
if sessionScrapeInstructions != "" {
hasInstructions := sessionScrapeInstructions != ""
if hasInstructions {
body.Instructions = &sessionScrapeInstructions
}
if sessionScrapeOnlyMain {
Expand All @@ -586,7 +587,7 @@ func runSessionScrape(cmd *cobra.Command, args []string) error {
return err
}

return GetFormatter().Print(resp.JSON200)
return PrintScrapeResponse(resp.JSON200, hasInstructions)
}

func runSessionCookies(cmd *cobra.Command, args []string) error {
Expand Down
5 changes: 3 additions & 2 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,9 @@ func TestTextFormatter_Print_PointerField(t *testing.T) {
}

output := buf.String()
if !strings.Contains(output, "<nil>") {
t.Errorf("expected <nil> for nil pointer fields, got %q", output)
// Nil pointer fields should be skipped entirely
if strings.Contains(output, "Name:") || strings.Contains(output, "Value:") {
t.Errorf("expected nil fields to be hidden, got %q", output)
}
})

Expand Down
Loading
Loading